diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000..cd8c34186 --- /dev/null +++ b/.gitignore @@ -0,0 +1,29 @@ +/.phpunit.cache +/node_modules +/public/default/* +/public/lucentui/* +/public/hot +/public/storage +/storage/*.key +/vendor +.env +.env.backup +.env.production +.phpunit.result.cache +Homestead.json +Homestead.yaml +auth.json +npm-debug.log +yarn-error.log +/.fleet +/.idea +/.vscode +themes/* +!themes/default +!themes/lucentui +*/.DS_Store +.DS_Store + +**/caddy +frankenphp +frankenphp-worker.php diff --git a/Captcha.php b/Captcha.php new file mode 100644 index 000000000..897ec6d1d --- /dev/null +++ b/Captcha.php @@ -0,0 +1,26 @@ +schema([ + Toggle::make('enabled')->default(true)->columnSpanFull(), + UserComponent::make('user_id'), + TextInput::make('code') + ->label('Referral Code') + ->required() + ->minLength(5) + ->maxLength(20) + ->unique(ignoreRecord: true), + TextInput::make('reward') + ->label('Affiliate Reward') + ->helperText('Percentage of the purchase amount the affiliated user would receive as a reward.') + ->placeholder($extension->config('default_reward')) + ->numeric() + ->minValue(0) + ->maxValue(100) + ->suffix('%'), + ]); + } + + public static function table(Table $table): Table + { + return $table + ->columns([ + TextColumn::make('user.name')->description(function (Affiliate $affiliate) { + if (count($affiliate->earnings) <= 0) { + return null; + } + + return 'Earnings - ' . implode(', ', array_map(function ($key, $value) { + return "$key: $value"; + }, array_keys($affiliate->earnings), $affiliate->earnings)); + }), + TextColumn::make('code') + ->badge(), + TextColumn::make('visitors') + ->numeric() + ->sortable(), + TextColumn::make('signups') + ->numeric() + ->sortable(), + TextColumn::make('created_at') + ->label('Signed Up') + ->since() + ->sortable() + ->dateTimeTooltip(), + ]) + ->filters([]) + ->actions([ + Tables\Actions\EditAction::make(), + ]) + ->bulkActions([ + Tables\Actions\BulkActionGroup::make([ + Tables\Actions\DeleteBulkAction::make(), + ]), + ]); + } + + public static function getRelations(): array + { + return [ + RelationManagers\AffiliatesRelationManager::class, + ]; + } + + public static function getPages(): array + { + return [ + 'index' => Pages\ListAffiliates::route('/'), + 'edit' => Pages\EditAffiliate::route('/{record}/edit'), + ]; + } + + protected function getHeaderActions(): array + { + return [ + $this->getCreateFormAction() + ->formId('form'), + ]; + } +} diff --git a/Disabled/Affiliates/Admin/Resources/AffiliateResource/Pages/CreateAffiliate.php b/Disabled/Affiliates/Admin/Resources/AffiliateResource/Pages/CreateAffiliate.php new file mode 100644 index 000000000..926cc41d0 --- /dev/null +++ b/Disabled/Affiliates/Admin/Resources/AffiliateResource/Pages/CreateAffiliate.php @@ -0,0 +1,11 @@ +recordTitleAttribute('affiliate_id') + ->columns([ + TextColumn::make('order.id'), + TextColumn::make('earnings')->formatStateUsing(function (AffiliateOrder $affiliateOrder) { + if (count($affiliateOrder->affiliate->earnings) <= 0) { + return null; + } + + return implode(', ', array_map(function ($key, $value) { + return "$key: $value"; + }, array_keys($affiliateOrder->affiliate->earnings), $affiliateOrder->affiliate->earnings)); + })->label('Earning'), + TextColumn::make('order.created_at') + ->label('Created At') + ->since() + ->sortable() + ->dateTimeTooltip(), + ]) + ->filters([ + // + ]) + ->actions([ + Tables\Actions\ViewAction::make()->url(fn ($record) => OrderResource::getUrl('edit', ['record' => $record->order])), + Tables\Actions\DeleteAction::make(), + ]) + ->bulkActions([ + Tables\Actions\BulkActionGroup::make([ + Tables\Actions\DeleteBulkAction::make(), + ]), + ]); + } +} diff --git a/Disabled/Affiliates/Affiliates.php b/Disabled/Affiliates/Affiliates.php new file mode 100644 index 000000000..5cfc16d36 --- /dev/null +++ b/Disabled/Affiliates/Affiliates.php @@ -0,0 +1,132 @@ + 'default_reward', + 'label' => 'Default Affiliate Reward', + 'type' => 'number', + 'description' => 'Percentage of the purchase amount the affiliated user would receive as a reward.', + 'required' => true, + 'suffix' => '%', + 'validation' => 'integer|min:0|max:100', + ], + [ + 'name' => 'cookie_max_age', + 'label' => 'Referral Cookie Max-Age', + 'type' => 'number', + 'description' => 'Amount of days for which the referral cookie be valid. (Set 0 for infinite)', + 'required' => true, + 'validation' => 'integer|min:0', + ], + [ + 'name' => 'type', + 'label' => 'Affiliate Code Type', + 'type' => 'select', + 'default' => 'random', + 'description' => 'How the affiliate would be assigned.', + 'required' => true, + 'options' => [ + 'random' => 'Random', + 'custom' => 'Custom', + ], + ], + ]; + } + + public function enabled() + { + // Run migrations + Artisan::call('migrate', ['--path' => 'extensions/Others/Affiliates/database/migrations/2024_12_25_075634_create_ext_affiliates_table.php', '--force' => true]); + Artisan::call('migrate', ['--path' => 'extensions/Others/Affiliates/database/migrations/2025_01_31_155928_create_ext_affiliate_orders_table.php', '--force' => true]); + } + + public function disabled() {} + + public function boot() + { + require __DIR__ . '/routes/web.php'; + View::addNamespace('affiliates', __DIR__ . '/resources/views'); + Lang::addNamespace('affiliates', __DIR__ . '/resources/lang'); + + Livewire::component('affiliate', AffiliateComponent::class); + + User::resolveRelationUsing('affiliate', function (User $userModel) { + return $userModel->hasOne(Affiliate::class, 'user_id'); + }); + + ExtensionHelper::registerMiddleware(AffiliatesMiddleware::class); + + // Listen for UserCreated and InvoicePaid events + Event::listen( + UserCreated::class, + IncreamentAffiliateSignups::class, + ); + Event::listen( + InvoicePaid::class, + RewardAffiliate::class, + ); + Event::listen( + OrderCreated::class, + AssociateOrderWithAffiliate::class, + ); + + Event::listen('api.permissions', function () { + return [ + 'admin.affiliates.view' => 'View Affiliates', + 'admin.affiliates.create' => 'Create Affiliates', + 'admin.affiliates.update' => 'Update Affiliates', + 'admin.affiliates.delete' => 'Delete Affiliates', + ]; + }); + + // Event::listen('navigation.dashboard', function ($routes) { + // dd($routes); + // return [ + // 'name' => __('affiliates::affiliate.affiliate'), + // 'route' => 'affiliate.index', + // 'icon' => 'heroicon-o-banknotes', + // 'group' => 'Administration', + // ]; + // }); + + // Hook onto account navigation + Event::listen('navigation.account', function () { + return [ + 'name' => __('affiliates::affiliate.affiliate'), + 'route' => 'affiliate.index', + ]; + }); + } +} diff --git a/Disabled/Affiliates/Http/Controllers/AffiliateController.php b/Disabled/Affiliates/Http/Controllers/AffiliateController.php new file mode 100644 index 000000000..6f2fa03b2 --- /dev/null +++ b/Disabled/Affiliates/Http/Controllers/AffiliateController.php @@ -0,0 +1,90 @@ +allowedFilters(['affiliate_id', 'code', 'visitors', 'reward', 'discount']) + ->allowedIncludes($this->allowedIncludes(self::INCLUDES)) + ->allowedSorts(['id', 'code', 'visitors', 'reward', 'discount', 'created_at']) + ->simplePaginate(request('per_page', 15)); + + // Return the affiliates as a JSON response + return AffiliateResource::collection($affiliates); + } + + /** + * Create a new affiliate + */ + public function store(CreateAffiliateRequest $request) + { + // Validate and create the affiliate + $affiliate = Affiliate::create($request->validated()); + + // Return the created affiliate as a JSON response + return new AffiliateResource($affiliate); + } + + /** + * Show a specific affiliate + */ + public function show(GetAffiliateRequest $request, Affiliate $affiliate) + { + $affiliate = QueryBuilder::for(Affiliate::class) + ->allowedIncludes($this->allowedIncludes(self::INCLUDES)) + ->findOrFail($affiliate->id); + + // Return the affiliate as a JSON response + return new AffiliateResource($affiliate); + } + + /** + * Update a specific affiliate + */ + public function update(UpdateAffiliateRequest $request, Affiliate $affiliate) + { + // Validate and update the affiliate + $affiliate->update($request->validated()); + + // Return the updated affiliate as a JSON response + return new AffiliateResource($affiliate); + } + + /** + * Delete a specific affiliate + */ + public function destroy(DeleteAffiliateRequest $request, Affiliate $affiliate) + { + // Delete the affiliate + $affiliate->delete(); + + return $this->returnNoContent(); + } +} diff --git a/Disabled/Affiliates/Http/Requests/CreateAffiliateRequest.php b/Disabled/Affiliates/Http/Requests/CreateAffiliateRequest.php new file mode 100644 index 000000000..ee6c89da6 --- /dev/null +++ b/Disabled/Affiliates/Http/Requests/CreateAffiliateRequest.php @@ -0,0 +1,20 @@ + 'required|exists:users,id', + 'code' => 'required|string|max:255|unique:affiliates,code', + 'enabled' => 'nullable|boolean', + 'reward' => 'nullable|numeric|min:0|max:100', + ]; + } +} diff --git a/Disabled/Affiliates/Http/Requests/DeleteAffiliateRequest.php b/Disabled/Affiliates/Http/Requests/DeleteAffiliateRequest.php new file mode 100644 index 000000000..a8e2890b0 --- /dev/null +++ b/Disabled/Affiliates/Http/Requests/DeleteAffiliateRequest.php @@ -0,0 +1,10 @@ + 'nullable|exists:users,id', + 'code' => 'nullable|string|max:255|unique:affiliates,code,' . $this->route()->parameter('affiliate')->id, + 'enabled' => 'nullable|boolean', + 'reward' => 'nullable|numeric|min:0|max:100', + ]; + } +} diff --git a/Disabled/Affiliates/Http/Resources/AffiliateOrderResource.php b/Disabled/Affiliates/Http/Resources/AffiliateOrderResource.php new file mode 100644 index 000000000..f9e4063d2 --- /dev/null +++ b/Disabled/Affiliates/Http/Resources/AffiliateOrderResource.php @@ -0,0 +1,14 @@ + UserResource::class, + 'orders' => AffiliateOrderResource::class, + ]; +} diff --git a/Disabled/Affiliates/Listeners/AssociateOrderWithAffiliate.php b/Disabled/Affiliates/Listeners/AssociateOrderWithAffiliate.php new file mode 100644 index 000000000..8111263e8 --- /dev/null +++ b/Disabled/Affiliates/Listeners/AssociateOrderWithAffiliate.php @@ -0,0 +1,38 @@ +first(); + if (!$affiliate || $affiliate->user->id === $event->order->user_id) { + return; + } + + AffiliateOrder::create([ + 'order_id' => $event->order->id, + 'affiliate_id' => $affiliate->id, + ]); + } +} diff --git a/Disabled/Affiliates/Listeners/IncreamentAffiliateSignups.php b/Disabled/Affiliates/Listeners/IncreamentAffiliateSignups.php new file mode 100644 index 000000000..1ff705146 --- /dev/null +++ b/Disabled/Affiliates/Listeners/IncreamentAffiliateSignups.php @@ -0,0 +1,34 @@ +first(); + if (!$affiliate) { + return; + } + + $affiliate->increment('signups'); + } +} diff --git a/Disabled/Affiliates/Listeners/RewardAffiliate.php b/Disabled/Affiliates/Listeners/RewardAffiliate.php new file mode 100644 index 000000000..a70147eba --- /dev/null +++ b/Disabled/Affiliates/Listeners/RewardAffiliate.php @@ -0,0 +1,71 @@ +invoice; + + if ($invoice->items()->first()->reference_type !== Service::class) { + return; + } + $order = $invoice->items()->first()->reference->order; + $referral = AffiliateOrder::where('order_id', $order->id)->first(); + + if (!$referral) { + return; + } + + /** + * @var Affiliate $affiliate + */ + $affiliate = $referral->affiliate; + $extension = ExtensionHelper::getExtension('other', 'Affiliates'); + $reward_percentage = $affiliate->reward ?: $extension->config('default_reward'); + $reward_amount = $invoice->total * $reward_percentage / 100; + + /** + * @var Collection + */ + $user_credits = $affiliate->user->credits; + $affiliate_credits = $user_credits->filter(function ($credit) use ($invoice) { + return $credit->currency_code === $invoice->currency_code; + })->first(); + + if ($affiliate_credits) { + // Add reward to credits + $affiliate->user->credits()->where('currency_code', $invoice->currency_code)->update([ + 'amount' => $affiliate_credits->amount + $reward_amount, + ]); + } else { + // Create new credits with the invoice's currency code + $affiliate->user->credits()->create([ + 'amount' => $reward_amount, + 'currency_code' => $invoice->currency_code, + ]); + } + } +} diff --git a/Disabled/Affiliates/Livewire/Affiliates/Affiliate.php b/Disabled/Affiliates/Livewire/Affiliates/Affiliate.php new file mode 100644 index 000000000..e0feb3c45 --- /dev/null +++ b/Disabled/Affiliates/Livewire/Affiliates/Affiliate.php @@ -0,0 +1,63 @@ +affiliate = Auth::user()->affiliate; + $this->signup_type = ExtensionHelper::getExtension('other', 'Affiliates')->config('type'); + } + + public function render() + { + return view('affiliates::index')->layoutData([ + 'sidebar' => true, + ]); + } + + public function signup() + { + $this->validate(); + + /** + * @var App\Models\User + */ + $user = Auth::user(); + if ($user->affiliate) { + $this->notify(__('affiliates::affiliate.you-are-already-affiliated')); + + return; + } + + $this->affiliate = $user->affiliate()->create([ + 'code' => $this->signup_type === 'custom' ? $this->referral_code : Str::random(10), + 'visitors' => 0, + 'reward' => null, + 'discount' => null, + ])->refresh(); + + $this->notify(__('affiliates::affiliate.signup-success')); + } + + public function rules() + { + return [ + 'referral_code' => [Rule::requiredIf($this->signup_type === 'custom'), 'alpha_num:ascii', 'unique:ext_affiliates,code', 'min:5', 'max:25'], + ]; + } +} diff --git a/Disabled/Affiliates/Middleware/AffiliatesMiddleware.php b/Disabled/Affiliates/Middleware/AffiliatesMiddleware.php new file mode 100644 index 000000000..74a7de5d8 --- /dev/null +++ b/Disabled/Affiliates/Middleware/AffiliatesMiddleware.php @@ -0,0 +1,50 @@ +has('ref')) { + return $next($request); + } + + $affiliate = Affiliate::where('code', request('ref'))->first(); + + if (!$affiliate || $affiliate->user->id === auth()->id()) { + return $next($request); + } + + // Increase the visitor count if a cookie is not present already, or is not the same one + if (!Cookie::has('referred_by') || Cookie::get('referred_by') !== $affiliate->code) { + $affiliate->increment('visitors'); + } + + $extension = ExtensionHelper::getExtension('other', 'Affiliates'); + $cookie_max_age = (int) $extension->config('cookie_max_age'); + + if ($cookie_max_age > 0) { + // Set affiliate cookie (and set it to expire after `$cookie_max_age` days) + Cookie::queue('referred_by', $affiliate->code, minutes: 60 * 24 * $cookie_max_age); + } else { + // Never expire + Cookie::queue(Cookie::forever('referred_by', $affiliate->code)); + } + + return $next($request); + } +} diff --git a/Disabled/Affiliates/Models/Affiliate.php b/Disabled/Affiliates/Models/Affiliate.php new file mode 100644 index 000000000..fc9f74c67 --- /dev/null +++ b/Disabled/Affiliates/Models/Affiliate.php @@ -0,0 +1,56 @@ +belongsTo(User::class); + } + + public function orders(): HasMany + { + return $this->hasMany(AffiliateOrder::class); + } + + /** + * Get the earnings made by this affiliate + * + * @return string + */ + public function earnings(): Attribute + { + return Attribute::make( + get: function (): array { + $earnings = []; + $this->orders->each(function ($order) use (&$earnings) { + foreach ($order->earnings as $currency => $total) { + if (!isset($earnings[$currency])) { + $earnings[$currency] = 0; + } + $earnings[$currency] += $total; + } + }); + + return $earnings; + }, + ); + } +} diff --git a/Disabled/Affiliates/Models/AffiliateOrder.php b/Disabled/Affiliates/Models/AffiliateOrder.php new file mode 100644 index 000000000..c09b6047c --- /dev/null +++ b/Disabled/Affiliates/Models/AffiliateOrder.php @@ -0,0 +1,67 @@ +belongsTo(Affiliate::class); + } + + public function order(): BelongsTo + { + return $this->belongsTo(Order::class); + } + + /** + * Get the earnings made from this order + * + * @return string + */ + public function earnings(): Attribute + { + return Attribute::make( + get: function (): array { + $earnings = []; + + /** @var Collection */ + $invoices = $this->order->invoices; + $extension = ExtensionHelper::getExtension('other', 'Affiliates'); + $reward_percentage = $this->affiliate->reward ?: $extension->config('default_reward'); + + $invoices->each(function ($invoice) use (&$earnings, $reward_percentage) { + if ($invoice->status !== 'paid') { + return; + } + if (!isset($earnings[$invoice->currency_code])) { + $earnings[$invoice->currency_code] = 0; + } + $earnings[$invoice->currency_code] += $invoice->total * $reward_percentage / 100; + }); + + foreach ($earnings as $currency => $total) { + // Round to 2 decimal places + $earnings[$currency] = round($total, 2); + } + + return $earnings; + }, + ); + } +} diff --git a/Disabled/Affiliates/database/migrations/2024_12_25_075634_create_ext_affiliates_table.php b/Disabled/Affiliates/database/migrations/2024_12_25_075634_create_ext_affiliates_table.php new file mode 100644 index 000000000..9a9d9ed35 --- /dev/null +++ b/Disabled/Affiliates/database/migrations/2024_12_25_075634_create_ext_affiliates_table.php @@ -0,0 +1,35 @@ +id(); + $table->foreignIdFor(User::class)->constrained()->cascadeOnDelete(); + $table->string('code')->unique(); + $table->boolean('enabled')->default(true); + $table->integer('reward')->unsigned()->nullable(); + $table->integer('discount')->unsigned()->nullable(); + $table->integer('visitors')->unsigned()->default(0); + $table->integer('signups')->unsigned()->default(0); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('affiliates'); + } +}; diff --git a/Disabled/Affiliates/database/migrations/2025_01_31_155928_create_ext_affiliate_orders_table.php b/Disabled/Affiliates/database/migrations/2025_01_31_155928_create_ext_affiliate_orders_table.php new file mode 100644 index 000000000..108ac32e2 --- /dev/null +++ b/Disabled/Affiliates/database/migrations/2025_01_31_155928_create_ext_affiliate_orders_table.php @@ -0,0 +1,30 @@ +id(); + $table->foreignIdFor(Affiliate::class)->constrained()->cascadeOnDelete(); + $table->foreignIdFor(Order::class)->unique()->constrained()->cascadeOnDelete(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('affiliate_orders'); + } +}; diff --git a/Disabled/Affiliates/resources/lang/en/affiliate.php b/Disabled/Affiliates/resources/lang/en/affiliate.php new file mode 100644 index 000000000..87e5e2263 --- /dev/null +++ b/Disabled/Affiliates/resources/lang/en/affiliate.php @@ -0,0 +1,19 @@ + 'Affiliate', + 'signup-for-affiliate' => 'Signup for Affiliate', + 'code' => 'Code', + 'visitors' => 'Visitors', + 'total-visitors' => 'Total Visitors', + 'signups' => 'Signups', + 'total-signups' => 'Total Signups', + 'earnings' => 'Earnings', + 'total-earnings' => 'Total Earnings', + 'your-affiliate-link' => 'Your affiliate link', + 'copy' => 'Copy', + + 'you-havent-signed-up-yet' => 'You have not signed up for affiliates program yet!', + 'you-are-already-affiliated' => 'You have already signed up for affiliate program.', + 'signup-success' => 'Successfully signed up for affiliate program!', +]; diff --git a/Disabled/Affiliates/resources/views/index.blade.php b/Disabled/Affiliates/resources/views/index.blade.php new file mode 100644 index 000000000..7c13b436b --- /dev/null +++ b/Disabled/Affiliates/resources/views/index.blade.php @@ -0,0 +1,73 @@ +
+ +
+ + @isset($affiliate) +
+
+ {{ __('affiliates::affiliate.visitors') }} + {{ __('affiliates::affiliate.total-visitors') }} + {{ Number::format($affiliate->visitors) }} +
+
+ {{ __('affiliates::affiliate.signups') }} + {{ __('affiliates::affiliate.total-signups') }} + + {{ Number::format($affiliate->signups) }} + +
+
+ {{ __('affiliates::affiliate.earnings') }} + {{ __('affiliates::affiliate.total-earnings') }} + +
    + @foreach ($affiliate->earnings as $currency => $amount) +
  • + {{ $currency }}: {{ $amount }} +
  • + @endforeach +
+
+
+
+ {{ __('affiliates::affiliate.affiliate') }} + {{ __('affiliates::affiliate.your-affiliate-link') }} +
+ + + {{ __('affiliates::affiliate.copy') }} +
+
+
+ + @else +

{{ __('affiliates::affiliate.you-havent-signed-up-yet') }}

+

{{ __('affiliates::affiliate.signup-for-affiliate') }}

+
+ + @if ($signup_type === 'custom') + + @endif + + + {{ __('auth.sign_up') }} + + + @endisset +
+ +
diff --git a/Disabled/Affiliates/routes/web.php b/Disabled/Affiliates/routes/web.php new file mode 100644 index 000000000..9227a35b0 --- /dev/null +++ b/Disabled/Affiliates/routes/web.php @@ -0,0 +1,15 @@ + ['web', 'auth']], function () { + Route::get('/account/affiliate', Affiliate::class)->name('affiliate.index'); +}); + +Route::group(['middleware' => ['api', 'api.admin'], 'prefix' => '/api/v1/admin'], function () { + Route::apiResources([ + 'affiliates' => AffiliateController::class, + ]); +}); diff --git a/Disabled/Credit/Credit.php b/Disabled/Credit/Credit.php new file mode 100644 index 000000000..8790db0d3 --- /dev/null +++ b/Disabled/Credit/Credit.php @@ -0,0 +1,81 @@ + 'start_credit', + 'type' => 'number', + 'label' => 'Initial Credits', + 'required' => true, + 'validation' => 'min:0', + 'description' => 'Amount of credit given to new users' + ], + [ + 'name' => 'currency_code', + 'type' => 'text', + 'label' => 'Currency Code', + 'required' => true, + 'default' => 'USD', + 'description' => '3-letter currency code for credits' + ] + ]; + } + + public function boot() + { + Log::info('Credit extension initialized'); + + Event::listen(UserCreated::class, function ($event) { + try { + $credits = (float) $this->config('start_credit'); + $currency = $this->config('currency_code', 'USD'); + $userId = $event->user->id; + + Log::info('Processing new user credits', [ + 'user_id' => $userId, + 'amount' => $credits, + 'currency' => $currency + ]); + + if ($credits > 0) { + DB::table('credits')->insert([ + 'user_id' => $userId, + 'currency_code' => $currency, + 'amount' => $credits, + 'created_at' => now(), + 'updated_at' => now() + ]); + + Log::info('Credits added successfully', [ + 'user_id' => $userId, + 'amount' => $credits, + 'currency' => $currency + ]); + } + } catch (\Exception $e) { + Log::error('Credit extension error', [ + 'error' => $e->getMessage(), + 'trace' => $e->getTraceAsString() + ]); + } + }); + } +} \ No newline at end of file diff --git a/Disabled/DiscordPlus/DiscordPlus.php b/Disabled/DiscordPlus/DiscordPlus.php new file mode 100644 index 000000000..6fb59519a --- /dev/null +++ b/Disabled/DiscordPlus/DiscordPlus.php @@ -0,0 +1,165 @@ +config('discord_bot_token'); + if (!$botToken) return; + + app()->bind(\App\Http\Controllers\SocialLoginController::class, function () { + return new \Paymenter\Extensions\Others\DiscordPlus\SocialLoginController(); + }); + + Event::listen(InvoiceCreated::class, function ($event) use ($botToken) { + $this->sendDm($event->invoice, 'invoice_created', $botToken); + }); + + Event::listen(InvoicePaid::class, function ($event) use ($botToken) { + $this->sendDm($event->invoice, 'invoice_paid', $botToken); + }); + } + + public function getConfig($values = []) + { + return [ + [ + 'name' => 'discord_bot_token', + 'type' => 'text', + 'label' => 'Discord Bot Token', + 'required' => true, + 'encrypted' => true, + ], + ]; + } + + private function sendDm($invoice, $type, $botToken) + { + if (!$invoice || !$invoice->user) { + return; + } + $user = $invoice->user; + $discordId = $user->discord_id; + $botToken = trim($botToken); + + if (empty($discordId) || !is_string($discordId) || !ctype_digit((string)$discordId)) { + return; + } + + $company = \Config::get('app.name', 'Paymenter'); + $invoiceUrl = \URL::to("/invoices/{$invoice->id}"); + $amount = (string)$invoice->formattedTotal; + $dueDate = $invoice->due_at ? $invoice->due_at->format('d M Y') : 'N/A'; + + if ($type === 'invoice_created') { + $embed = [ + 'embeds' => [[ + 'title' => '🧾 New Invoice Created', + 'color' => 0x7289DA, + 'description' => "A new invoice has been generated for your account.\n[View & Pay Invoice]({$invoiceUrl})", + 'fields' => [ + [ 'name' => 'Invoice #', 'value' => "#{$invoice->number}", 'inline' => true ], + [ 'name' => 'Amount Due', 'value' => $amount, 'inline' => true ], + [ 'name' => 'Due Date', 'value' => ($dueDate !== 'N/A' ? "`{$dueDate}`" : '*N/A*'), 'inline' => false ], + ], + 'footer' => [ 'text' => $company ], + ]] + ]; + } else { + $embed = [ + 'embeds' => [[ + 'title' => '✅ Invoice Paid', + 'color' => 0x43B581, + 'description' => "Thank you for your payment!\n[View Invoice]({$invoiceUrl})", + 'fields' => [ + [ 'name' => 'Invoice #', 'value' => "#{$invoice->number}", 'inline' => true ], + [ 'name' => 'Amount', 'value' => $amount, 'inline' => true ], + ], + 'footer' => [ 'text' => $company ], + ]] + ]; + } + + $dmResponse = \Http::withHeaders([ + 'Authorization' => 'Bot ' . $botToken, + 'Content-Type' => 'application/json', + ]) + ->post('https://discord.com/api/v10/users/@me/channels', [ + 'recipient_id' => $discordId, + ]); + + if ($dmResponse->failed()) { + \Log::error('[DiscordPlus] Failed to create DM channel for discord_id: ' . $discordId . '. Response: ' . $dmResponse->body()); + return; + } + + $channelId = $dmResponse->json('id'); + if (!$channelId) { + \Log::error('[DiscordPlus] No channel ID returned for discord_id: ' . $discordId . '. Response: ' . $dmResponse->body()); + return; + } + + $msgResponse = \Http::withHeaders([ + 'Authorization' => 'Bot ' . $botToken, + 'Content-Type' => 'application/json', + ]) + ->post("https://discord.com/api/v10/channels/{$channelId}/messages", $embed); + + if ($msgResponse->failed()) { + \Log::error('[DiscordPlus] Failed to send DM to channel: ' . $channelId . '. Response: ' . $msgResponse->body()); + } + } + + public function enabled(): void + { + if (!\Schema::hasColumn('users', 'discord_id')) { + \Artisan::call('migrate', [ + '--path' => 'extensions/Others/DiscordPlus/migrations/2025_08_27_000000_add_discord_id_to_users_table.php', + '--force' => true, + ]); + } + } + public function disabled(): void {} + public function updated(): void {} + public function install(): void {} +} diff --git a/Disabled/DiscordPlus/SocialLoginController.php b/Disabled/DiscordPlus/SocialLoginController.php new file mode 100644 index 000000000..c32ae503c --- /dev/null +++ b/Disabled/DiscordPlus/SocialLoginController.php @@ -0,0 +1,111 @@ +discord_driver = Socialite::buildProvider(DiscordProvider::class, [ + 'client_id' => config('settings.oauth_discord_client_id'), + 'client_secret' => config('settings.oauth_discord_client_secret'), + 'redirect' => '/oauth/discord/callback', + ]); + $this->github_driver = Socialite::buildProvider(GithubProvider::class, [ + 'client_id' => config('settings.oauth_github_client_id'), + 'client_secret' => config('settings.oauth_github_client_secret'), + 'redirect' => '/oauth/github/callback', + ]); + $this->google_driver = Socialite::buildProvider(GoogleProvider::class, [ + 'client_id' => config('settings.oauth_google_client_id'), + 'client_secret' => config('settings.oauth_google_client_secret'), + 'redirect' => '/oauth/google/callback', + ]); + } + + public function redirect($provider) + { + if (!config("settings.oauth_$provider")) { + abort(404); + } + + return match ($provider) { + 'discord' => $this->discord_driver->scopes(['email'])->redirect(), + 'github' => $this->github_driver->scopes(['user:email'])->redirect(), + 'google' => $this->google_driver->scopes(['email'])->redirect(), + default => abort(404) + }; + } + + public function handle($provider) + { + if ($provider == 'discord') { + $oauth_user = $this->discord_driver->user(); + + if (!($oauth_user->user['verified'] ?? true)) { + return redirect()->route('login')->with('error', __('auth.oauth.unverified_discord_account')); + } + + $user = User::where('email', $oauth_user->email)->first(); + if (!$user) { + return redirect()->route('register')->with('error', __('auth.oauth.account_not_registered')); + } + + // Only set discord_id if it exists and is not already set + $discordId = $oauth_user->getId() ?? ($oauth_user->id ?? $oauth_user->user['id'] ?? null); + if ($discordId && (!$user->discord_id || $user->discord_id != $discordId)) { + $user->discord_id = $discordId; + $user->save(); + } + + Auth::login($user, true); + + return redirect()->route('home'); + } elseif ($provider == 'google') { + $oauth_user = $this->google_driver->user(); + + $user = User::where('email', $oauth_user->email)->first(); + if (!$user) { + return redirect()->route('register')->with('error', __('auth.oauth.account_not_registered')); + } + + Auth::login($user, true); + + return redirect()->route('home'); + } elseif ($provider == 'github') { + $oauth_user = $this->github_driver->user(); + + $user = User::where('email', $oauth_user->email)->first(); + if (!$user) { + return redirect()->route('register')->with('error', __('auth.oauth.account_not_registered')); + } + + Auth::login($user, true); + + return redirect()->route('home'); + } else { + return redirect()->route('login'); + } + } +} diff --git a/Disabled/DiscordPlus/migrations/2025_08_27_000000_add_discord_id_to_users_table.php b/Disabled/DiscordPlus/migrations/2025_08_27_000000_add_discord_id_to_users_table.php new file mode 100644 index 000000000..fc25560e2 --- /dev/null +++ b/Disabled/DiscordPlus/migrations/2025_08_27_000000_add_discord_id_to_users_table.php @@ -0,0 +1,25 @@ +string('discord_id')->nullable()->unique()->after('email'); + } + }); + } + + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + if (Schema::hasColumn('users', 'discord_id')) { + $table->dropColumn('discord_id'); + } + }); + } +}; diff --git a/Disabled/FreeTrial/FreeTrial.php b/Disabled/FreeTrial/FreeTrial.php new file mode 100644 index 000000000..bcbb9d3c9 --- /dev/null +++ b/Disabled/FreeTrial/FreeTrial.php @@ -0,0 +1,61 @@ + 'max_order_value', + 'label' => 'Max Order Value', + 'type' => 'number', + 'required' => true, + ] + ]; + } + + /** + * Return a view or a url to redirect to + * + * @param Invoice $invoice + * @param float $total + * @return string + */ + public function pay(Invoice $invoice, $total) + { + $items = $invoice->items; + $maxOrderValue = $this->config('max_order_value'); + + // Make sure that the invoice is not for adding credits + foreach ($items as $item) { + if ($item->reference_type == 'App\\Models\\Credit') { + return redirect()->route('invoices.show', ['invoice' => $invoice->id]) + ->with('notification', ['message' => 'Cannot be used on Credits', 'type' => 'error']); + } + } + + if ($total > $maxOrderValue) { + return redirect()->route('invoices.show', ['invoice' => $invoice->id]) + ->with('notification', ['message' => 'Item not eligible', 'type' => 'error']); + } + + ExtensionHelper::addPayment($invoice->id, 'FreeTrial', $total); + return redirect()->route('invoices.show', ['invoice' => $invoice->id])->with('notification', [ + 'message' => 'Trial Activated', + 'type' => 'success' + ]); + } +} diff --git a/Disabled/FreeTrial/LICENSE b/Disabled/FreeTrial/LICENSE new file mode 100644 index 000000000..7c349ef43 --- /dev/null +++ b/Disabled/FreeTrial/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 Vaibhav Dhiman + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Disabled/FreeTrial/README.md b/Disabled/FreeTrial/README.md new file mode 100644 index 000000000..21ee6bf8d --- /dev/null +++ b/Disabled/FreeTrial/README.md @@ -0,0 +1,60 @@ +# Free Trial Payment Gateway for Paymenter + +A simple extension for [Paymenter](https://paymenter.org) that allows automatic invoice approval for orders under a configurable maximum value. Ideal for offering **free trials** or promotional services with zero friction. + +## Supported Versions +> The last version which supported Paymenter v0.9.5 is [v1.0.0](https://github.com/VaibhavSys/FreeTrial-Paymenter-Extension/releases/tag/v1.0.0) +- **Supported Version:** Paymenter v1.0.4 +- **Extension Version:** v2.0.0 + +## Features +- Automatically approves invoices if the total is under a configured maximum value. +- Seamless free trial support — no real payment is processed. +- Lightweight and easy to configure. + +> ⚠️ **Note:** This gateway cannot be used to credit user accounts. It only applies to invoices which do not add credits to a user account. + +## How It Works + +Once enabled, if a user attempts to pay an invoice and the total amount is **less than or equal to** the configured **Max Order Value**, the payment is **automatically marked as paid**. If the amount exceeds the limit, the payment is **rejected**. + +This gives you full flexibility to: +- Create promotional plans priced under the trial limit. +- Use one-time coupons and products to offer free trials. +- Test services internally without actual transactions. + +## Getting Started + +1. **Install the extension** + - Replace `/var/www/paymenter` with your actual Paymenter installation path. + - **One-liner Install:** + ```sh + git clone https://github.com/VaibhavSys/FreeTrial-Paymenter-Extension.git /var/www/paymenter/extensions/Gateways/FreeTrial + ``` + - **Manual Install:** + [Download](https://github.com/VaibhavSys/FreeTrial-Paymenter-Extension/releases/latest/download/FreeTrial.zip) the extension and extract it to: + ``` + /var/www/paymenter/extensions/Gateways + ``` +1. **Enable the extension** from the Paymenter admin panel. +1. **Configure the Max Order Value** in the admin panel (e.g., `0.5` to allow automatic approval for invoices ≤ $0.5). + +That’s it — you’re ready to offer free trials! + +## Configuration + +- **Max Order Value (number):** The maximum invoice amount (in your invoice currency) that can be auto-approved. Any invoice above this value will be rejected. + +![Settings](Settings.png) + +> 💡 If you want to use this with a one-time promotional coupon/product, ensure the final payable invoice amount (after discount) is within the Max Order Value. + +## Support + +For questions, suggestions, or issues: +- **Discord:** [@vaibhavd](https://discord.com/users/914452175839723550) +- **GitHub Issues:** [FreeTrial-Paymenter Issues](https://github.com/VaibhavSys/FreeTrial-Paymenter-Extension/issues) + +## Additional Information + +- **License:** MIT — see [LICENSE](LICENSE) for details. diff --git a/Disabled/FreeTrial/Settings.png b/Disabled/FreeTrial/Settings.png new file mode 100644 index 000000000..6879e68c2 Binary files /dev/null and b/Disabled/FreeTrial/Settings.png differ diff --git a/Disabled/FreeTrial/cover.png b/Disabled/FreeTrial/cover.png new file mode 100644 index 000000000..0ef6dcce0 Binary files /dev/null and b/Disabled/FreeTrial/cover.png differ diff --git a/Disabled/FreeTrial/logo.png b/Disabled/FreeTrial/logo.png new file mode 100644 index 000000000..64cb476a7 Binary files /dev/null and b/Disabled/FreeTrial/logo.png differ diff --git a/Disabled/Mollie/Mollie.php b/Disabled/Mollie/Mollie.php new file mode 100644 index 000000000..3277a3a2f --- /dev/null +++ b/Disabled/Mollie/Mollie.php @@ -0,0 +1,83 @@ + 'Bearer ' . $this->config('api_key'), + 'Content-Type' => 'application/json', + ])->$method('https://api.mollie.com' . $url, $data); + + if (!$response->successful()) { + throw new \Exception('Mollie API error: ' . $response->json()['detail']); + } + + return $response->json(); + } + + /** + * Get all the configuration for the extension + * + * @param array $values + * @return array + */ + public function getConfig($values = []) + { + return [ + [ + 'name' => 'api_key', + 'label' => 'API Key', + 'type' => 'text', + 'required' => true, + ], + ]; + } + + /** + * Return a view or a url to redirect to + * + * @param float $total + * @return string + */ + public function pay(Invoice $invoice, $total) + { + $response = $this->request('/v2/payments', 'post', [ + 'amount' => [ + 'currency' => $invoice->currency_code, + 'value' => number_format($total, 2, '.', ''), + ], + 'description' => 'Invoice #' . $invoice->id, + 'redirectUrl' => route('invoices.show', $invoice) . '?checkPayment=true', + 'webhookUrl' => route('extensions.gateways.mollie.webhook', $invoice), + 'metadata' => [ + 'invoice_id' => $invoice->id, + ], + ]); + + return $response['_links']['checkout']['href']; + } + + public function webhook(Request $request) + { + $payment = $this->request('/v2/payments/' . $request->input('id')); + + if ($payment['status'] == 'paid') { + ExtensionHelper::addPayment($payment['metadata']['invoice_id'], 'Mollie', $payment['amount']['value'], transactionId: $payment['id']); + } + } +} diff --git a/Disabled/Mollie/routes.php b/Disabled/Mollie/routes.php new file mode 100644 index 000000000..a9a3d8a90 --- /dev/null +++ b/Disabled/Mollie/routes.php @@ -0,0 +1,7 @@ +withoutMiddleware([VerifyCsrfToken::class])->name('extensions.gateways.mollie.webhook'); diff --git a/Disabled/Pages/Admin/Resources/PageResource.php b/Disabled/Pages/Admin/Resources/PageResource.php new file mode 100644 index 000000000..9ef8a6de3 --- /dev/null +++ b/Disabled/Pages/Admin/Resources/PageResource.php @@ -0,0 +1,115 @@ +schema([ + Forms\Components\TextInput::make('title') + ->required() + ->maxLength(255) + ->live(onBlur: true) + ->afterStateUpdated(function (Get $get, Set $set, ?string $old, ?string $state) { + if (($get('slug') ?? '') !== Str::slug($old)) { + return; + } + + $set('slug', Str::slug($state)); + }), + Forms\Components\TextInput::make('slug')->rule('regex:/^[a-z0-9-]+$/') + ->required()->unique(ignoreRecord: true), + Forms\Components\Textarea::make('description'), + Forms\Components\RichEditor::make('content')->enableToolbarButtons()->hidden(fn (Get $get) => $get('as_html')), + Forms\Components\MarkdownEditor::make('content')->disableAllToolbarButtons()->hidden(fn (Get $get) => !$get('as_html')), + Forms\Components\Toggle::make('visible') + ->label('Visible'), + Forms\Components\Toggle::make('as_html') + ->live() + ->label('Show content as raw HTML (also removes title from the page)'), + Forms\Components\Select::make('visibility') + ->options([ + 'public' => __('Public'), + 'client' => __('Customers Only'), + 'admin' => __('Admin Only'), + ]) + ->default('public'), + Forms\Components\Select::make('navigation') + ->options([ + 'none' => __('None (hidden)'), + 'top' => __('Top Navigation'), + 'account_dropdown' => __('Account Dropdown (requires login)'), + 'dashboard' => __('Client area/Dashboard (requires login)'), + ]) + ->default('none') + ->label('Show in navigation'), + + ]); + } + + public static function table(Table $table): Table + { + return $table + ->columns([ + Tables\Columns\TextColumn::make('title') + ->label('Title') + ->searchable() + ->sortable(), + Tables\Columns\TextColumn::make('slug') + ->label('Slug') + ->searchable() + ->sortable(), + Tables\Columns\IconColumn::make('visible') + ->label('Visible') + ->boolean(), + ]) + ->filters([ + // + ]) + ->actions([ + Tables\Actions\EditAction::make(), + ]) + ->bulkActions([ + Tables\Actions\BulkActionGroup::make([ + Tables\Actions\DeleteBulkAction::make(), + ]), + ]) + ->reorderable('sort') + ->defaultSort('sort', 'asc'); + } + + public static function getRelations(): array + { + return [ + // + ]; + } + + public static function getPages(): array + { + return [ + 'index' => Pages\ListPages::route('/'), + 'create' => Pages\CreatePage::route('/create'), + 'edit' => Pages\EditPage::route('/{record}/edit'), + ]; + } +} diff --git a/Disabled/Pages/Admin/Resources/PageResource/Pages/CreatePage.php b/Disabled/Pages/Admin/Resources/PageResource/Pages/CreatePage.php new file mode 100644 index 000000000..79e653f5b --- /dev/null +++ b/Disabled/Pages/Admin/Resources/PageResource/Pages/CreatePage.php @@ -0,0 +1,11 @@ +page = ModelsPage::where('slug', $fallbackPlaceholder)->firstOrFail(); + if (!$this->page->visible) { + abort(404); + } + // Validate if user is logged in + if ($this->page->visibility == 'client' && !auth()->check()) { + abort(404); + } + if ($this->page->visibility == 'admin' && (!auth()->check() || is_null(auth()->user()->role))) { + abort(404); + } + } + + public function render() + { + return view('others.pages::page', [ + 'page' => $this->page, + ])->layout('layouts.app', [ + 'title' => $this->page->title, + 'description' => $this->page->description, + 'sidebar' => $this->page->navigation == 'dashboard' ? true : false, + ]); + } +} diff --git a/Disabled/Pages/Models/Page.php b/Disabled/Pages/Models/Page.php new file mode 100644 index 000000000..d9711e57d --- /dev/null +++ b/Disabled/Pages/Models/Page.php @@ -0,0 +1,33 @@ + 'boolean', + 'html' => 'boolean', + 'visibility' => 'string', + ]; + + public function getRouteKeyName() + { + return 'slug'; + } +} diff --git a/Disabled/Pages/Pages.php b/Disabled/Pages/Pages.php new file mode 100644 index 000000000..b3c14fc7f --- /dev/null +++ b/Disabled/Pages/Pages.php @@ -0,0 +1,108 @@ + 'Notice', + 'type' => 'placeholder', + 'label' => new HtmlString('You can use this extension to create custom frontend pages, go to Pages to get started.'), + ], + ]; + } catch (\Exception $e) { + return [ + [ + 'name' => 'Notice', + 'type' => 'placeholder', + 'label' => new HtmlString('You can use this extension to create custom frontend pages, you\'ll need to enable this extension above to get started.'), + ], + ]; + } + } + + public function enabled() + { + // Run migrations + Artisan::call('migrate', ['--path' => 'extensions/Others/Pages/database/migrations', '--force' => true]); + } + + public function boot() + { + // Register routes + require __DIR__ . '/routes.php'; + View::addNamespace('others.pages', __DIR__ . '/resources/views'); + + // Register livewire + \Livewire\Livewire::component('extensions.others.pages.show', Page::class); + + Event::listen('navigation', function () { + if (ModelsPage::where('visible', true)->where('navigation', 'top')->count() == 0) { + return; + } + + $pages = ModelsPage::where('visible', true)->where('navigation', 'top')->orderBy('sort')->get(); + + return $this->toNavigation($pages); + }); + + Event::listen('navigation.account-dropdown', function () { + if (ModelsPage::where('visible', true)->where('navigation', 'account_dropdown')->count() == 0) { + return; + } + + $pages = ModelsPage::where('visible', true)->where('navigation', 'account_dropdown')->orderBy('sort')->get(); + + return $this->toNavigation($pages); + }); + + Event::listen('navigation.dashboard', function () { + if (ModelsPage::where('visible', true)->where('navigation', 'dashboard')->count() == 0) { + return; + } + + $pages = ModelsPage::where('visible', true)->where('navigation', 'dashboard')->orderBy('sort')->get(); + + return $this->toNavigation($pages); + }); + } + + private function toNavigation($pages) + { + $navigation = []; + foreach ($pages as $page) { + if ($page->visibility == 'client' && !auth()->check()) { + continue; + } + if ($page->visibility == 'admin' && (!auth()->check() || is_null(auth()->user()->role))) { + continue; + } + $navigation[] = [ + 'name' => $page->title, + 'route' => 'extensions.others.pages', + 'params' => ['fallbackPlaceholder' => $page->slug], + ]; + } + + return $navigation; + } +} diff --git a/Disabled/Pages/database/migrations/2025_04_16_141103_create_ext_pages_table.php b/Disabled/Pages/database/migrations/2025_04_16_141103_create_ext_pages_table.php new file mode 100644 index 000000000..3ed8d9133 --- /dev/null +++ b/Disabled/Pages/database/migrations/2025_04_16_141103_create_ext_pages_table.php @@ -0,0 +1,36 @@ +id(); + $table->string('slug')->unique(); + $table->string('title'); + $table->text('description')->nullable(); + $table->longText('content')->nullable(); + $table->boolean('visible')->default(true); + $table->boolean('as_html')->default(false); + $table->enum('visibility', ['public', 'client', 'admin'])->default('public'); + $table->enum('navigation', ['none', 'top', 'account_dropdown', 'dashboard'])->default('none'); + $table->unsignedTinyInteger('sort')->nullable(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('ext_pages'); + } +}; diff --git a/Disabled/Pages/readme.md b/Disabled/Pages/readme.md new file mode 100644 index 000000000..3b8718683 --- /dev/null +++ b/Disabled/Pages/readme.md @@ -0,0 +1,8 @@ +Thanks for buying this extension! + +Buying this extension means you are supporting the development of Paymenter and helping us to keep it free for everyone. + +To install this extension just drop it in the `extensions/Others` folder of your Paymenter installation. You can find the `extensions/Others` folder in the root directory of your Paymenter installation. + + +If you require assistance with this extension, please go to the [Paymenter Discord](https://discord.gg/kJWDZ86NUZ) and ask for help in the 3rd party support channel. \ No newline at end of file diff --git a/Disabled/Pages/resources/views/page.blade.php b/Disabled/Pages/resources/views/page.blade.php new file mode 100644 index 000000000..41dd792e7 --- /dev/null +++ b/Disabled/Pages/resources/views/page.blade.php @@ -0,0 +1,8 @@ +
+ @if($page->as_html) + {!! $page->content !!} + @else +

{{ $page->title }}

+
{!! $page->content !!}
+ @endif +
\ No newline at end of file diff --git a/Disabled/Pages/routes.php b/Disabled/Pages/routes.php new file mode 100644 index 000000000..28ed5d781 --- /dev/null +++ b/Disabled/Pages/routes.php @@ -0,0 +1,6 @@ +middleware('web')->name('extensions.others.pages'); diff --git a/Disabled/PayPal_IPN/PayPal_IPN.php b/Disabled/PayPal_IPN/PayPal_IPN.php new file mode 100644 index 000000000..fc370726b --- /dev/null +++ b/Disabled/PayPal_IPN/PayPal_IPN.php @@ -0,0 +1,87 @@ + 'email', + 'label' => 'PayPal Email', + 'type' => 'text', + 'required' => true, + ], + [ + 'name' => 'test_mode', + 'label' => 'Test Mode', + 'type' => 'checkbox', + 'required' => false, + ], + ]; + } + + /** + * Return a view or a url to redirect to + * + * @param float $total + * @return string + */ + public function pay(Invoice $invoice, $total) + { + $paypal_url = $this->config('test_mode') ? 'https://www.sandbox.paypal.com/cgi-bin/webscr' : 'https://www.paypal.com/cgi-bin/webscr'; + $paypal_email = $this->config('email'); + $return_url = route('invoices.show', $invoice); + $cancel_url = route('invoices.show', $invoice); + + $notify_url = route('extensions.gateways.paypal_ipn.notify'); + + $query = '?cmd=_xclick'; + $query .= '&item_name=' . urlencode('Invoice #' . $invoice->id); + $query .= '&item_number=' . $invoice->id; + $query .= '&amount=' . number_format($total, 2, '.', ''); + $query .= '¤cy_code=' . $invoice->currency_code; + $query .= '&business=' . $paypal_email; + $query .= '¬ify_url=' . $notify_url; + $query .= '&return=' . $return_url; + $query .= '&cancel_return=' . $cancel_url; + + return $paypal_url . $query; + } + + /** + * Handle the IPN request + * + * @return void + */ + public function notify(Request $request) + { + // Send the request to PayPal + $response = Http::asForm()->post($this->config('test_mode') ? 'https://ipnpb.sandbox.paypal.com/cgi-bin/webscr' : 'https://ipnpb.paypal.com/cgi-bin/webscr', [ + 'cmd' => '_notify-validate', + ] + $request->all()); + + // Check if the response is verified + if ($response->body() == 'VERIFIED') { + ExtensionHelper::addPayment($request->item_number, 'PayPal', $request->mc_gross, $request->mc_fee, transactionId: $request->txn_id); + } + } +} diff --git a/Disabled/PayPal_IPN/routes.php b/Disabled/PayPal_IPN/routes.php new file mode 100644 index 000000000..b9a5f4619 --- /dev/null +++ b/Disabled/PayPal_IPN/routes.php @@ -0,0 +1,6 @@ +name('extensions.gateways.paypal_ipn.notify'); diff --git a/Disabled/ProductPageBuilder/Admin/Resources/Pages/CreateProductPage.php b/Disabled/ProductPageBuilder/Admin/Resources/Pages/CreateProductPage.php new file mode 100644 index 000000000..ab5fe82f7 --- /dev/null +++ b/Disabled/ProductPageBuilder/Admin/Resources/Pages/CreateProductPage.php @@ -0,0 +1,11 @@ +schema([ + Section::make('Basic Information') + ->schema([ + FileUpload::make('hero_section.background') + ->image() + ->disk('public') + ->directory('product-pages/hero') + ->required() + ->label('Background Image'), + TextInput::make('hero_section.title') + ->required() + ->live(onBlur: true) + ->label('Title') + ->afterStateUpdated(function ($state, callable $set) { + $set('hero_section.slug', \Str::slug($state)); + $set('seo.title', $state); + }), + TextInput::make('hero_section.slug') + ->required() + ->unique( + table: 'ext_product_page_builder', + column: 'hero_section->slug', + ignorable: fn ($record) => $record + ) + ->label('URL Slug'), + RichEditor::make('hero_section.description') + ->required() + ->label('Description') + ->afterStateUpdated(function ($state, callable $set) { + $set('seo.description', strip_tags($state)); + }), + Select::make('category_id') + ->label('Category') + ->options(Category::all()->pluck('name', 'id')) + ->required(), + ]) + ->collapsible(), + + Section::make('SEO Settings') + ->schema([ + TextInput::make('seo.title') + ->label('SEO Title') + ->placeholder('Will use Basic Information title if left empty'), + TextInput::make('seo.url') + ->label('Canonical URL') + ->placeholder('Auto-generated from slug') + ->dehydrated(false) + ->disabled() + ->formatStateUsing(fn ($record) => $record ? url('/product/' . ($record->hero_section['slug'] ?? '')) : '' + ), + FileUpload::make('seo.banner') + ->image() + ->disk('public') + ->directory('product-pages/seo') + ->label('Social Media Banner'), + Textarea::make('seo.description') + ->label('SEO Description') + ->placeholder('Will use Basic Information description if left empty') + ->rows(3), + /* + ================================================================================== + This only work if you inside themes/default/views/layouts/app.blade.php + + {{-- + @isset($keywords) + + @endisset + --}} + + delete {{-- and --}} + + and also in extension/Others/ProductPageBuilder/resources/views/show.blade.php + + {{-- + {{ is_array($page->seo['keywords'] ?? null) ? implode(', ', $page->seo['keywords']) : ($page->seo['keywords'] ?? '') }} + --}} + + delete {{-- and --}} + + TagsInput::make('seo.keywords') + ->label('SEO Keywords') + ->placeholder('Add keywords and press enter') + ->separator(','), + + ================================================================================== + */ + ]) + ->collapsible(), + + Section::make('Features') + ->schema([ + Repeater::make('features') + ->collapsible() + ->schema([ + Select::make('icon_type') + ->label('Icon Type') + ->options([ + 'image' => 'Image', + 'svg' => 'SVG', + ]) + ->default('image') + ->required() + ->reactive() + ->formatStateUsing(function ($state, $record) { + if (!empty($state)) { + return $state; + } + if (!empty($record['icon_svg'])) { + return 'svg'; + } + if (!empty($record['icon'])) { + return 'image'; + } + + return 'image'; + }), + Textarea::make('icon_svg') + ->label('SVG Code') + ->rows(5) + ->placeholder('...') + ->visible(fn ($get) => $get('icon_type') === 'svg'), + FileUpload::make('icon') + ->directory('product-pages/features') + ->visible(fn ($get) => $get('icon_type') === 'image'), + TextInput::make('title'), + // ->required(), + RichEditor::make('description'), + // ->required(), + Toggle::make('is_active') + ->default(true), + ]), + // ->required() + ]) + ->collapsible(), + + Section::make('Game Preview') + ->schema([ + Repeater::make('game_preview') + ->maxItems(1) + ->schema([ + TextInput::make('title'), + // ->required(), + RichEditor::make('description'), + // ->required(), + TextInput::make('youtube_embed') + // ->required() + ->placeholder('e.g. https://www.youtube.com/embed/xxxxx'), + Toggle::make('is_active') + ->default(true), + ]), + // ->required() + ]) + ->collapsible(), + + Section::make('Game Panel') + ->schema([ + Repeater::make('game_panel') + ->maxItems(1) + ->schema([ + TextInput::make('title'), + // ->required(), + RichEditor::make('description'), + // ->required(), + FileUpload::make('gallery') + // ->required() + ->multiple() + ->directory('product-pages/gallery'), + TagsInput::make('features') + ->label('Panel Features') + ->placeholder('Add feature and press enter') + ->separator(','), + Toggle::make('is_active') + ->default(true), + ]), + // ->required() + ]) + ->collapsible(), + + Section::make('FAQs') + ->schema([ + Repeater::make('faqs') + ->collapsible() + ->schema([ + TextInput::make('question'), + // ->required(), + RichEditor::make('answer'), + // ->required(), + Toggle::make('is_active') + ->default(true), + ]), + // ->required() + ]) + ->collapsible(), + ]); + } + + public static function table(Table $table): Table + { + return $table + ->columns([ + Tables\Columns\TextColumn::make('updated_at') + ->label('Last Updated') + ->dateTime() + ->sortable(), + Tables\Columns\TextColumn::make('hero_section.title') + ->label('Title'), + Tables\Columns\TextColumn::make('hero_section.slug') + ->label('Slug'), + ]) + ->actions([ + Tables\Actions\EditAction::make(), + Tables\Actions\ViewAction::make() + ->url(fn ($record) => "/games/{$record->hero_section['slug']}") + ->openUrlInNewTab(), + Tables\Actions\DeleteAction::make(), + ]) + /* + ->headerActions([ + Tables\Actions\CreateAction::make() + ->label('Create Product Page') + ])*/ + ->defaultSort('updated_at', 'desc'); + } + + public static function getPages(): array + { + return [ + 'index' => Pages\ListProductPages::route('/'), + 'create' => Pages\CreateProductPage::route('/create'), + 'edit' => Pages\EditProductPage::route('/{record}/edit'), + ]; + } +} diff --git a/Disabled/ProductPageBuilder/Http/Controllers/ProductPageController.php b/Disabled/ProductPageBuilder/Http/Controllers/ProductPageController.php new file mode 100644 index 000000000..cda0b6a95 --- /dev/null +++ b/Disabled/ProductPageBuilder/Http/Controllers/ProductPageController.php @@ -0,0 +1,23 @@ +slug', $slug)->firstOrFail(); + + return view('product_page_builder::show', compact('page')); + } +} diff --git a/Disabled/ProductPageBuilder/Models/ProductPageSettings.php b/Disabled/ProductPageBuilder/Models/ProductPageSettings.php new file mode 100644 index 000000000..2f9199a48 --- /dev/null +++ b/Disabled/ProductPageBuilder/Models/ProductPageSettings.php @@ -0,0 +1,70 @@ + 'array', + 'features' => 'array', + 'pricing_tables' => 'array', + 'seo' => 'array', + 'game_preview' => 'array', + 'game_panel' => 'array', + 'faqs' => 'array', + ]; + + protected function heroSection(): Attribute + { + return Attribute::make( + get: fn ($value) => json_decode($value, true) ?? [], + set: fn ($value) => json_encode($value), + ); + } + + public function category() + { + return $this->belongsTo(Category::class); + } + + public function getMinPriceDetailsAttribute() + { + if (!$this->category) { + return null; + } + + $minPrice = $this->category->products() + ->join('plans', function ($join) { + $join->on('plans.priceable_id', '=', 'products.id') + ->where('plans.priceable_type', '=', 'App\\Models\\Product'); + }) + ->join('prices', function ($join) { + $join->on('prices.plan_id', '=', 'plans.id'); + }) + ->select('prices.price', 'prices.currency_code') + ->orderBy('prices.price', 'asc') + ->first(); + + return $minPrice ? [ + 'price' => $minPrice->price, + 'currency_code' => $minPrice->currency_code, + ] : null; + } +} diff --git a/Disabled/ProductPageBuilder/ProductPageBuilder.php b/Disabled/ProductPageBuilder/ProductPageBuilder.php new file mode 100644 index 000000000..3551f3b96 --- /dev/null +++ b/Disabled/ProductPageBuilder/ProductPageBuilder.php @@ -0,0 +1,89 @@ + 'notice', + 'type' => 'placeholder', + 'label' => 'Use this extension to customize your product pages with custom sections.', + ], + ]; + } + + public function enabled() + { + Artisan::call('migrate', [ + '--path' => 'extensions/Others/ProductPageBuilder/database/migrations', + '--force' => true, + ]); + } + + public function boot() + { + // Add category_id column if it doesn't exist + if (!$this->columnExists('ext_product_page_builder', 'category_id')) { + DB::statement('ALTER TABLE ext_product_page_builder ADD COLUMN category_id bigint unsigned NULL'); + DB::statement('ALTER TABLE ext_product_page_builder ADD CONSTRAINT product_page_builder_category_id_foreign FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE SET NULL'); + } + + // Register views + View::addNamespace('product_page_builder', __DIR__ . '/resources/views'); + + // Register routes + Route::middleware('web')->group(__DIR__ . '/routes/web.php'); + + // Register Filament resources + if (class_exists('\Filament\Facades\Filament')) { + Filament::registerResources([ + Admin\Resources\ProductPageResource::class, + ]); + } + + Event::listen('product.page', function ($product) { + $settings = ProductPageSettings::first(); + if (!$settings) { + return []; + } + + return [ + 'view' => view('product_page_builder::product', [ + 'settings' => $settings, + 'product' => $product, + ]), + ]; + }); + + Event::listen('navigation', function () { + if (ProductPageSettings::count() == 0) { + return; + } + + return [ + 'name' => 'Games', + 'route' => 'product-page.index', + // 'icon' => '', + 'separator' => true, + 'children' => [], + ]; + }); + } + + private function columnExists($table, $column) + { + return DB::getSchemaBuilder()->hasColumn($table, $column); + } +} diff --git a/Disabled/ProductPageBuilder/ProductPageBuilderServiceProvider.php b/Disabled/ProductPageBuilder/ProductPageBuilderServiceProvider.php new file mode 100644 index 000000000..65da9a18e --- /dev/null +++ b/Disabled/ProductPageBuilder/ProductPageBuilderServiceProvider.php @@ -0,0 +1,20 @@ +loadRoutesFrom(__DIR__ . '/routes/web.php'); + $this->loadMigrationsFrom(__DIR__ . '/database/migrations'); + $this->loadViewsFrom(__DIR__ . '/resources/views', 'product-page-builder'); + } + + public function register() + { + // + } +} diff --git a/Disabled/ProductPageBuilder/composer.json b/Disabled/ProductPageBuilder/composer.json new file mode 100644 index 000000000..4ba66035e --- /dev/null +++ b/Disabled/ProductPageBuilder/composer.json @@ -0,0 +1,10 @@ +{ + "name": "paymenter/product-page-builder", + "extra": { + "laravel": { + "providers": [ + "Paymenter\\Extensions\\Others\\ProductPageBuilder\\ProductPageBuilderServiceProvider" + ] + } + } +} diff --git a/Disabled/ProductPageBuilder/database/migrations/2024_01_01_000001_create_product_page_builder_table.php b/Disabled/ProductPageBuilder/database/migrations/2024_01_01_000001_create_product_page_builder_table.php new file mode 100644 index 000000000..9c85c5186 --- /dev/null +++ b/Disabled/ProductPageBuilder/database/migrations/2024_01_01_000001_create_product_page_builder_table.php @@ -0,0 +1,30 @@ +id(); + $table->json('hero_section')->nullable(); + $table->json('features')->nullable(); + $table->json('seo')->nullable(); + $table->json('game_preview')->nullable(); + $table->json('game_panel')->nullable(); + $table->json('faqs')->nullable(); + $table->foreignId('category_id')->nullable()->constrained('categories')->onDelete('set null'); + $table->timestamps(); + }); + } + + public function down() + { + Schema::dropIfExists('ext_product_page_builder'); + } +}; diff --git a/Disabled/ProductPageBuilder/resources/views/components/custom-select.blade.php b/Disabled/ProductPageBuilder/resources/views/components/custom-select.blade.php new file mode 100644 index 000000000..a9379b522 --- /dev/null +++ b/Disabled/ProductPageBuilder/resources/views/components/custom-select.blade.php @@ -0,0 +1,119 @@ +@props([ + 'options' => [], + 'placeholder' => 'Select option' +]) + +
+ + + +
\ No newline at end of file diff --git a/Disabled/ProductPageBuilder/resources/views/index.blade.php b/Disabled/ProductPageBuilder/resources/views/index.blade.php new file mode 100644 index 000000000..6a20b7fe1 --- /dev/null +++ b/Disabled/ProductPageBuilder/resources/views/index.blade.php @@ -0,0 +1,47 @@ + + Games + + +
+ @foreach($pages as $page) +
+ @if(isset($page->hero_section['background'])) +
+ {{ $page->hero_section['title'] }} +
+ @endif + +
+

{{ $page->hero_section['title'] }}

+ @if(isset($page->hero_section['description'])) +
+ {!! strip_tags($page->hero_section['description']) !!} +
+ @endif + +
+ @if($page->min_price_details) +
+ @php + $currency = \App\Models\Currency::firstWhere('code', $page->min_price_details['currency_code']); + $formattedPrice = $currency ? + ($currency->prefix ?? '') . number_format($page->min_price_details['price'], 2) . ($currency->suffix ?? '') : + number_format($page->min_price_details['price'], 2); + @endphp + Starting from {{ $formattedPrice }} +
+ @endif +
+ + +
+
+ @endforeach +
+
diff --git a/Disabled/ProductPageBuilder/resources/views/show.blade.php b/Disabled/ProductPageBuilder/resources/views/show.blade.php new file mode 100644 index 000000000..06029b8bb --- /dev/null +++ b/Disabled/ProductPageBuilder/resources/views/show.blade.php @@ -0,0 +1,273 @@ + + {{ $page->seo['title'] ?? $page->hero_section['title'] }} + {!! $page->seo['description'] ?? strip_tags($page->hero_section['description']) !!} + {{-- + {{ is_array($page->seo['keywords'] ?? null) ? implode(', ', $page->seo['keywords']) : ($page->seo['keywords'] ?? '') }} + --}} + @if(isset($page->seo['banner'])) + {{ Storage::url($page->seo['banner']) }} + @endif + + + @if(isset($page->hero_section)) +
+ @if(isset($page->hero_section['background'])) +
+ {{ $page->hero_section['title'] }} +
+
+ @endif + +
+

+ {{ $page->hero_section['title'] }} +

+
+ {!! $page->hero_section['description'] !!} +
+
+
+ @endif + + @if($page->category) +
+
+ @foreach($page->category->products()->with(['plans.prices'])->get() as $product) + @php + $lowestPlan = $product->plans + ->flatMap(function($plan) { + return $plan->prices->map(function($price) use ($plan) { + return ['plan' => $plan, 'price' => $price]; + }); + }) + ->sortBy('price.price') + ->first(); + @endphp +
+ {{-- Prdocut Image Banner + @if($product->image) +
+ {{ $product->name }} +
+ @endif + --}} + {{-- Product Image Icon --}} + @if($product->image) +
+ {{ $product->name }} +
+ @endif +

+ {{ $product->name }} +

+
{!! $product->description !!}
+ @if($lowestPlan) + @php + $currency = \App\Models\Currency::firstWhere('code', $lowestPlan['price']->currency_code); + $formattedPrice = $currency ? + ($currency->prefix ?? '') . number_format($lowestPlan['price']->price, 2) . ($currency->suffix ?? '') : + number_format($lowestPlan['price']->price, 2); + @endphp +
+ Starting from + {{ $formattedPrice }} +
+ @endif + + {{ __('general.view') }} + +
+ @endforeach +
+
+ @endif + + @php + $activeFeatures = collect($page->features ?? [])->filter(fn($feature) => $feature['is_active'] ?? true); + @endphp + + @if($activeFeatures->isNotEmpty()) +
+

Features

+
+ @foreach($activeFeatures as $feature) +
+
+ @if(!empty($feature['icon_svg'])) +
+ {!! $feature['icon_svg'] !!} +
+ @elseif(isset($feature['icon'])) +
+ {{ $feature['title'] }} +
+ @endif +

{{ $feature['title'] }}

+
{!! $feature['description'] !!}
+
+
+ @endforeach +
+
+ @endif + + + @if(!empty($page->game_preview)) + @foreach($page->game_preview as $preview) + @if($preview['is_active'] ?? true) +
+
+

{{ $preview['title'] }}

+
{!! $preview['description'] !!}
+
+ @if(isset($preview['youtube_embed'])) +
+ +
+ @endif +
+ @endif + @endforeach + @endif + + @php + $activeGamePanels = collect($page->game_panel ?? [])->filter(fn($panel) => $panel['is_active'] ?? true); + $activeGamePreviews = collect($page->game_preview ?? [])->filter(fn($preview) => $preview['is_active'] ?? true); + $swapOrder = $activeGamePanels->isNotEmpty() && $activeGamePreviews->isNotEmpty(); + @endphp + + @if(!empty($page->game_panel)) + @foreach($page->game_panel as $panel) + @if($panel['is_active'] ?? true) +
+
+

{{ $panel['title'] }}

+
{!! $panel['description'] !!}
+ @php + $features = is_array($panel['features'] ?? null) + ? $panel['features'] + : (empty($panel['features']) ? [] : explode(',', $panel['features'])); + @endphp + @if(!empty($features)) +
+ @foreach($features as $feature) + + + + + {{ trim($feature) }} + + @endforeach +
+ @endif +
+ @if(!empty($panel['gallery'])) +
+
+ +
+ +
+ +
+ + + +
+ @endif +
+ @endif + @endforeach + @endif + + @php + $activeFaqs = collect($page->faqs ?? [])->filter(fn($faq) => $faq['is_active'] ?? true); + @endphp + + @if($activeFaqs->isNotEmpty()) +
+
+

Frequently Asked Questions

+
+ @foreach($activeFaqs as $faq) +
+
+ +
+
+
+ {!! $faq['answer'] !!} +
+
+
+ @endforeach +
+
+
+ @endif + +
diff --git a/Disabled/ProductPageBuilder/routes/web.php b/Disabled/ProductPageBuilder/routes/web.php new file mode 100644 index 000000000..28594ce72 --- /dev/null +++ b/Disabled/ProductPageBuilder/routes/web.php @@ -0,0 +1,7 @@ +name('product-page.index'); +Route::get('/games/{slug}', [ProductPageController::class, 'show'])->name('product-page.show'); diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 000000000..003202c31 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,41 @@ +# Stage 0: +# Build the assets that are needed for the frontend. This build stage is then discarded +# since we won't need NodeJS anymore in the future. This Docker image ships a final production +# level distribution of Paymenter. +FROM --platform=$TARGETOS/$TARGETARCH node:22-alpine AS build +WORKDIR /app +COPY . ./ +RUN npm install \ + && npm run build + +# Stage 1: +# Build the actual container with all of the needed PHP dependencies that will run the application. +FROM --platform=$TARGETOS/$TARGETARCH php:8.3-fpm-alpine +WORKDIR /app + +COPY . ./ +COPY --from=0 /app/public/default /app/public/default +RUN apk add --no-cache --update ca-certificates dcron curl git supervisor tar unzip nginx libpng-dev libxml2-dev libzip-dev icu-dev autoconf make g++ gcc libc-dev linux-headers gmp-dev \ + && docker-php-ext-configure zip \ + && docker-php-ext-install bcmath gd pdo_mysql zip intl sockets gmp \ + && pecl install redis \ + && docker-php-ext-enable redis \ + && apk del autoconf make g++ gcc libc-dev \ + && curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer \ + && cp .env.example .env \ + && chmod 777 -R bootstrap storage/* \ + && composer install --no-dev --optimize-autoloader \ + && rm -rf .env bootstrap/cache/*.php \ + && chown -R nginx:nginx . + +RUN rm /usr/local/etc/php-fpm.conf \ + && echo "* * * * * /usr/local/bin/php /app/artisan schedule:run >> /dev/null 2>&1" >> /var/spool/cron/crontabs/root \ + && mkdir -p /var/run/php /var/run/nginx + +COPY .github/docker/default.conf /etc/nginx/http.d/default.conf +COPY .github/docker/www.conf /usr/local/etc/php-fpm.conf +COPY .github/docker/supervisord.conf /etc/supervisord.conf + +EXPOSE 80 +ENTRYPOINT [ "/bin/ash", ".github/docker/entrypoint.sh" ] +CMD [ "supervisord", "-n", "-c", "/etc/supervisord.conf" ] diff --git a/Dockerfile.dev b/Dockerfile.dev new file mode 100644 index 000000000..452748d29 --- /dev/null +++ b/Dockerfile.dev @@ -0,0 +1,36 @@ +# Use PHP 8.2 FPM Debian as base image +FROM serversideup/php:8.3-fpm-nginx AS base + +# Switch to root so we can do root things +USER root + +# Install the intl extension with root permissions +RUN install-php-extensions pdo_mysql mbstring exif pcntl bcmath gd intl zip xml curl redis + +# Install JavaScript dependencies +ARG NODE_VERSION=20.18.0 +ENV PATH=/usr/local/node/bin:$PATH +RUN curl -sL https://github.com/nodenv/node-build/archive/master.tar.gz | tar xz -C /tmp/ && \ + /tmp/node-build-master/bin/node-build "${NODE_VERSION}" /usr/local/node && \ + corepack enable && \ + rm -rf /tmp/node-build-master + +# Drop back to our unprivileged user +USER www-data +FROM base + +# # Get latest Composer +# COPY --from=composer:latest /usr/bin/composer /usr/bin/composer + +# # Set working directory +# WORKDIR /var/www/html + +COPY --chown=www-data:www-data ./ /var/www/html + +ENV PHP_OPCACHE_ENABLE=1 + +RUN /usr/bin/composer install --no-interaction --prefer-dist --optimize-autoloader + +RUN npm install --immutable && \ + npm run build && \ + rm -rf node_modules diff --git a/app/Admin/Actions/AuditAction.php b/app/Admin/Actions/AuditAction.php new file mode 100644 index 000000000..df99265b2 --- /dev/null +++ b/app/Admin/Actions/AuditAction.php @@ -0,0 +1,44 @@ +auditChildren = $children; + + // Array of children of the model to also show audits for + return $this; + } + + public static function getDefaultName(): ?string + { + return 'audits'; + } + + protected function setUp(): void + { + parent::setUp(); + + $this + ->label('Audits') + ->color('gray') + ->slideOver() + ->modalContent(fn (Model $record): View => view( + 'admin.actions.audits', + ['record' => $record, 'children' => $this->auditChildren] + )) + ->modalHeading('test') + ->modalWidth(MaxWidth::Large) + ->modalSubmitAction(false) + ->modalCancelAction(false); + } +} diff --git a/app/Admin/Clusters/Services.php b/app/Admin/Clusters/Services.php new file mode 100644 index 000000000..01d7da7f8 --- /dev/null +++ b/app/Admin/Clusters/Services.php @@ -0,0 +1,18 @@ +label('User') + ->relationship('user', 'id') + ->searchable() + ->preload() + ->getOptionLabelFromRecordUsing(fn ($record) => $record->name . ' (' . $record->email . ')') + ->getSearchResultsUsing(fn (string $search): array => User::where('first_name', 'like', "%$search%") + ->orWhere('last_name', 'like', "%$search%") + ->limit(50) + ->get() + ->mapWithKeys(fn ($user) => [$user->id => $user->name . ' (' . $user->email . ')']) + ->toArray()) + ->hint(fn ($get) => $get('user_id') ? new \Illuminate\Support\HtmlString('Go to User') : null) + ->live() + ->required(); + } +} diff --git a/app/Admin/Pages/Dashboard.php b/app/Admin/Pages/Dashboard.php new file mode 100644 index 000000000..6758794a7 --- /dev/null +++ b/app/Admin/Pages/Dashboard.php @@ -0,0 +1,12 @@ + $settings) { + foreach ($settings as $setting) { + $setting_values[$setting['name']] = config("settings.{$setting['name']}", $setting['default'] ?? null); + } + } + + $this->form->fill($setting_values); + } + + public function form(Form $form): Form + { + $tabs = []; + + foreach (ClassesSettings::settings() as $key => $categories) { + $tab = Tabs\Tab::make($key) + ->label(ucwords(str_replace('-', ' ', $key))) + ->schema(function () use ($categories, $key) { + $inputs = []; + foreach ($categories as $setting) { + $inputs[] = FilamentInput::convert($setting); + } + if ($key === 'theme') { + // Add a reset colors button if there are color settings + array_unshift($inputs, Actions::make([ + Actions\Action::make('resetColors') + ->label('Reset Colors') + ->color('danger') + ->requiresConfirmation() + ->action(fn () => $this->resetColors()), + ])); + // Wrap the first two inputs in a group if there are more than one + if (count($inputs) > 1) { + $inputs[0] = Group::make([ + $inputs[1]->columnSpan(3), + $inputs[0], + ])->columns(4)->columnSpanFull(); + unset($inputs[1]); + } + } + + return $inputs; + }); + + $tabs[] = $tab; + } + + return $form + ->schema([ + Tabs::make('Tabs') + ->tabs($tabs) + ->persistTabInQueryString(), + ]) + ->statePath('data'); + } + + public function save(): void + { + Gate::authorize('has-permission', 'admin.settings.update'); + + $data = $this->form->getState(); + + $settings = Setting::where('settingable_type', null) + ->whereIn('key', array_keys($data)) + ->get() + ->keyBy('key'); + + foreach ($data as $key => $value) { + // Get only the settings that have changed + $avSetting = (object) collect(\App\Classes\Settings::settings())->flatten(1)->firstWhere('name', $key); + $avSetting->value = $settings[$key]->value ?? $avSetting->default ?? null; + + if ($value !== $avSetting->value || (($avSetting->database_type ?? 'string') === 'boolean' && (bool) $value !== (bool) $avSetting->value)) { + if ($setting = $settings[$key] ?? null) { + $setting->update([ + 'value' => $value, + 'type' => $avSetting->database_type ?? 'string', + ]); + } else { + Setting::create([ + 'key' => $key, + 'value' => $value, + 'settingable_type' => null, + 'type' => $avSetting->database_type ?? 'string', + 'encrypted' => $avSetting->encrypted ?? false, + ]); + } + } + } + + Notification::make() + ->title('Saved successfully!') + ->success() + ->send(); + } + + public function resetColors(): void + { + Gate::authorize('has-permission', 'admin.settings.update'); + + $colorSettings = []; + foreach (\App\Classes\Settings::settings() as $group => $settings) { + foreach ($settings as $setting) { + if (($setting['type'] ?? '') === 'color') { + $colorSettings[$setting['name']] = $setting['default'] ?? ''; + } + } + } + + $currentData = $this->form->getState(); + foreach ($colorSettings as $key => $defaultValue) { + $currentData[$key] = $defaultValue; + } + $this->form->fill($currentData); + + Notification::make() + ->title('Colors has been reset!') + ->success() + ->send(); + } + + public static function getFormActions(): array + { + return [ + Actions\Action::make('save') + ->submit('save') + ->keyBindings(['mod+s']), + ]; + } + + public static function canAccess(): bool + { + /** @var \App\Models\User */ + $user = auth()->user(); + + return $user && $user->hasPermission('admin.settings.view'); + } +} diff --git a/app/Admin/Pages/Updates.php b/app/Admin/Pages/Updates.php new file mode 100644 index 000000000..676521589 --- /dev/null +++ b/app/Admin/Pages/Updates.php @@ -0,0 +1,65 @@ +action(function () { + Artisan::call(CheckForUpdates::class); + }) + ->label('Check for updates'), + ]; + } + + public function update(): Action + { + return Action::make('update') + ->requiresConfirmation() + ->action(function () { + $output = new BufferedOutput; + + // Check if current chdir is the root of the project + if (getcwd() !== base_path()) { + chdir(base_path()); + } + + if (config('app.version') == 'beta') { + Artisan::call(Upgrade::class, ['--no-interaction' => true, '--url' => 'https://api.paymenter.org/beta'], $output); + } else { + Artisan::call(Upgrade::class, ['--no-interaction' => true], $output); + } + $this->dispatch('update-completed', [ + 'output' => $output->fetch(), + ]); + }) + ->label('Update'); + } + + public static function canAccess(): bool + { + return auth()->check() && auth()->user()->hasPermission('admin.updates.update'); + } +} diff --git a/app/Admin/Resources/ApiResource.php b/app/Admin/Resources/ApiResource.php new file mode 100644 index 000000000..1134906c7 --- /dev/null +++ b/app/Admin/Resources/ApiResource.php @@ -0,0 +1,91 @@ + Arr::dot( + array_merge_recursive(...Event::dispatch('api.permissions', [])) + )); + + return $form + ->schema([ + Forms\Components\TextInput::make('name') + ->required() + ->maxLength(255) + ->label('API Name'), + Forms\Components\TagsInput::make('ip_addresses') + ->label('Allowed IP Addresses') + ->placeholder('Enter IP addresses'), + Forms\Components\Toggle::make('enabled') + ->default(true) + ->visibleOn('edit') + ->label('Active Status'), + + Forms\Components\CheckboxList::make('permissions') + ->options(array_merge(Arr::dot(config('permissions.api')), $extensionApiPermissions)) + ->columns(4) + ->bulkToggleable() + ->searchable() + ->noSearchResultsMessage('Permission could not be found'), + + ]) + ->columns(1); + } + + public static function table(Table $table): Table + { + return $table + ->columns([ + Tables\Columns\TextColumn::make('name'), + Tables\Columns\TextColumn::make('ip_addresses') + ->formatStateUsing(fn ($state) => is_array($state) ? implode(', ', $state) : $state) + ->label('Allowed IP Addresses'), + Tables\Columns\IconColumn::make('enabled') + ->boolean(), + Tables\Columns\TextColumn::make('last_used_at') + ->dateTime() + ->label('Last Used At') + ->sortable() + ->toggleable(), + ]) + ->filters([ + // + ]) + ->actions([ + Tables\Actions\EditAction::make(), + Tables\Actions\DeleteAction::make(), + ]) + ->bulkActions([ + Tables\Actions\BulkActionGroup::make([ + Tables\Actions\DeleteBulkAction::make(), + ]), + ]); + } + + public static function getPages(): array + { + return [ + 'index' => Pages\ManageApis::route('/'), + ]; + } +} diff --git a/app/Admin/Resources/ApiResource/Pages/ManageApis.php b/app/Admin/Resources/ApiResource/Pages/ManageApis.php new file mode 100644 index 000000000..0165f2a9a --- /dev/null +++ b/app/Admin/Resources/ApiResource/Pages/ManageApis.php @@ -0,0 +1,35 @@ +mutateFormDataUsing(function (array $data): array { + $token = 'PAYM' . bin2hex(random_bytes(32)); + $data['token'] = hash('sha256', $token); + $data['type'] = 'admin'; + + Notification::make() + ->title('API Token Created') + ->body(Str::markdown("Here is your new API token. Please copy it now, as it will not be shown again.\nToken: ```" . $token . '```')) + ->icon('heroicon-o-key') + ->success() + ->persistent() + ->send(); + + return $data; + }), + ]; + } +} diff --git a/app/Admin/Resources/CategoryResource.php b/app/Admin/Resources/CategoryResource.php new file mode 100644 index 000000000..166d24207 --- /dev/null +++ b/app/Admin/Resources/CategoryResource.php @@ -0,0 +1,118 @@ +schema([ + Forms\Components\TextInput::make('name') + ->required() + ->maxLength(255) + ->live(onBlur: true) + ->afterStateUpdated(function (Get $get, Set $set, ?string $old, ?string $state) { + if (($get('slug') ?? '') !== Str::slug($old)) { + return; + } + + $set('slug', Str::slug($state)); + }), + Forms\Components\TextInput::make('slug') + ->required(), + Forms\Components\RichEditor::make('description') + ->required(), + Forms\Components\Select::make('parent_id') + ->relationship('parent', 'name') + ->searchable() + ->preload() + ->label('Parent Category') + // Disallow having same category as it's own parent + ->disableOptionWhen(fn (string $value, ?Category $record): bool => $record && (int) $value === $record->id), + Forms\Components\FileUpload::make('image')->label('Image')->nullable()->acceptedFileTypes(['image/*'])->columnSpanFull(), + ])->columns(2); + } + + public static function table(Table $table): Table + { + return $table + ->columns([ + Tables\Columns\TextColumn::make('name') + ->searchable() + ->sortable(), + Tables\Columns\TextColumn::make('slug') + ->searchable() + ->sortable(), + ]) + ->filters([ + // + ]) + ->actions([ + Tables\Actions\EditAction::make(), + ]) + ->bulkActions([ + Tables\Actions\BulkActionGroup::make([ + Tables\Actions\DeleteBulkAction::make()->before(function (DeleteBulkAction $action) { + foreach ($action->getRecords() as $record) { + if ($record->products()->exists() || $record->children()->exists()) { + Notification::make() + ->title('Cannot delete category') + ->body('The category has products or children categories.') + ->duration(5000) + ->icon('ri-error-warning-line') + ->danger() + ->send(); + $action->cancel(); + } + } + }), + ]), + ]) + ->defaultSort(function (Builder $query): Builder { + return $query + ->orderBy('sort', 'asc'); + }) + ->reorderable('sort'); + } + + public static function getRelations(): array + { + return [ + // + ]; + } + + public static function getPages(): array + { + return [ + 'index' => Pages\ListCategories::route('/'), + 'create' => Pages\CreateCategory::route('/create'), + 'edit' => Pages\EditCategory::route('/{record}/edit'), + ]; + } +} diff --git a/app/Admin/Resources/CategoryResource/Pages/CreateCategory.php b/app/Admin/Resources/CategoryResource/Pages/CreateCategory.php new file mode 100644 index 000000000..60adbba1a --- /dev/null +++ b/app/Admin/Resources/CategoryResource/Pages/CreateCategory.php @@ -0,0 +1,11 @@ +before(function (Actions\DeleteAction $action) { + if ($this->record->products()->exists() || $this->record->children()->exists()) { + Notification::make() + ->title('Cannot delete category') + ->body('The category has products or children categories.') + ->duration(5000) + ->icon('ri-error-warning-line') + ->danger() + ->send(); + $action->cancel(); + } + }), + ]; + } +} diff --git a/app/Admin/Resources/CategoryResource/Pages/ListCategories.php b/app/Admin/Resources/CategoryResource/Pages/ListCategories.php new file mode 100644 index 000000000..c34357c90 --- /dev/null +++ b/app/Admin/Resources/CategoryResource/Pages/ListCategories.php @@ -0,0 +1,19 @@ +schema([ + Forms\Components\Select::make('custom_property_id')->label('Custom Property') + ->required() + ->options(function ($livewire): array { + return CustomProperty::where('model', get_class($livewire->ownerRecord))->pluck('name', 'id')->toArray(); + })->nullable(), + Forms\Components\TextInput::make('name')->translateLabel()->nullable(), + Forms\Components\TextInput::make('key')->translateLabel()->required(), + Forms\Components\TextInput::make('value')->translateLabel()->required(), + ]); + } + + public function table(Table $table): Table + { + return $table + ->recordTitleAttribute('name') + ->columns([ + Tables\Columns\TextColumn::make('name'), + Tables\Columns\TextColumn::make('parent_property.name'), + Tables\Columns\TextColumn::make('key'), + Tables\Columns\TextInputColumn::make('value'), + ]) + ->filters([ + // + ]) + ->headerActions([ + Tables\Actions\CreateAction::make(), + ]) + ->actions([ + Tables\Actions\EditAction::make(), + Tables\Actions\DeleteAction::make(), + ]) + ->bulkActions([ + Tables\Actions\BulkActionGroup::make([ + Tables\Actions\DeleteBulkAction::make(), + ]), + ]); + } +} diff --git a/app/Admin/Resources/ConfigOptionResource.php b/app/Admin/Resources/ConfigOptionResource.php new file mode 100644 index 000000000..a59834157 --- /dev/null +++ b/app/Admin/Resources/ConfigOptionResource.php @@ -0,0 +1,162 @@ +schema([ + Forms\Components\Tabs::make() + ->columnSpanFull() + ->schema([ + Forms\Components\Tabs\Tab::make('General')->schema([ + Forms\Components\TextInput::make('name') + ->label('Name') + ->required() + ->maxLength(255) + ->placeholder('Enter the name of the configuration option'), + Forms\Components\TextInput::make('env_variable') + ->label('Environment Variable') + ->maxLength(255) + ->placeholder('Enter the environment variable name'), + Forms\Components\Select::make('type') + ->label('Type') + ->native(false) + ->required() + ->reactive() + ->options([ + 'text' => 'Text', + 'number' => 'Number', + 'select' => 'Select', + 'radio' => 'Radio', + 'checkbox' => 'Checkbox', + 'slider' => 'Slider', + ]), + Forms\Components\Checkbox::make('hidden') + ->label('Hidden'), + Forms\Components\Checkbox::make('upgradable') + ->visible(fn (Get $get): bool => in_array($get('type'), ['select', 'radio', 'slider'])) + ->label('Upgradable') + ->helperText('If enabled, this configuration option can be upgraded in the future.'), + Forms\Components\Select::make('products') + ->label('Products') + ->relationship('products', 'name') + ->multiple() + ->preload() + ->placeholder('Select the products that this configuration option belongs to'), + ]), + Forms\Components\Tabs\Tab::make('Options') + ->visible(fn (Get $get): bool => in_array($get('type'), ['select', 'radio', 'slider', 'checkbox'])) + ->schema([ + Forms\Components\Repeater::make('Options') + ->relationship('children') + ->label('Options') + ->addActionLabel('Add Option') + ->columnSpanFull() + ->itemLabel(fn (array $state) => $state['name']) + ->collapsible() + ->collapsed() + ->cloneable() + ->reorderable() + ->orderColumn('sort') + ->columns(2) + // When the type is checkbox only allow 1 child + ->maxItems(function (Get $get): ?int { + if (in_array($get('type'), ['select', 'radio', 'slider'])) { + return null; // unlimited children + } + + return 1; // checkbox + }) + ->minItems(1) + ->schema([ + Forms\Components\TextInput::make('name') + ->label('Name') + ->required() + ->live() + ->maxLength(255) + ->placeholder('Enter the name of the configuration option'), + Forms\Components\TextInput::make('env_variable') + ->label('Environment Variable') + ->required() + ->maxLength(255) + ->placeholder('Enter the environment variable name'), + // if the type is select, radio or checkbox then allow unlimited children (otherwise only allow 1) + ProductResource::plan()->columnSpanFull()->label('Pricing')->reorderable(false)->deleteAction(null), + ]), + ]), + ]), + ]); + } + + public static function table(Table $table): Table + { + return $table + ->columns([ + Tables\Columns\TextColumn::make('name') + ->label('Name') + ->searchable() + ->sortable(), + Tables\Columns\TextColumn::make('env_variable') + ->label('Environment Variable') + ->searchable() + ->sortable(), + Tables\Columns\TextColumn::make('type') + ->label('Type') + ->searchable() + ->sortable(), + Tables\Columns\TextColumn::make('hidden') + ->badge() + ->label('Hidden') + ->sortable(), + ]) + ->modifyQueryUsing(fn (Builder $query) => $query->where('parent_id', null)) + ->filters([ + // + ]) + ->actions([ + Tables\Actions\EditAction::make(), + ]) + ->defaultSort(function (Builder $query): Builder { + return $query + ->orderBy('sort', 'asc'); + }) + ->reorderable('sort'); + } + + public static function getRelations(): array + { + return [ + // + ]; + } + + public static function getPages(): array + { + return [ + 'index' => Pages\ListConfigOptions::route('/'), + 'create' => Pages\CreateConfigOption::route('/create'), + 'edit' => Pages\EditConfigOption::route('/{record}/edit'), + ]; + } +} diff --git a/app/Admin/Resources/ConfigOptionResource/Pages/CreateConfigOption.php b/app/Admin/Resources/ConfigOptionResource/Pages/CreateConfigOption.php new file mode 100644 index 000000000..b08b13868 --- /dev/null +++ b/app/Admin/Resources/ConfigOptionResource/Pages/CreateConfigOption.php @@ -0,0 +1,11 @@ +requiresConfirmation() + ->modalDescription( + fn (ConfigOption $record) => $record->children()->exists() + ? 'This config option has services connected to it. Deleting it will also delete it from the services it is associated with. Are you sure you want to delete this config option?' + : 'Are you sure you want to delete this config option?', + ) + ->action(function () { + $this->record->serviceConfigs()->delete(); + $this->record->delete(); + + return redirect()->to(ConfigOptionResource::getUrl()); + }), + ]; + } +} diff --git a/app/Admin/Resources/ConfigOptionResource/Pages/ListConfigOptions.php b/app/Admin/Resources/ConfigOptionResource/Pages/ListConfigOptions.php new file mode 100644 index 000000000..ed7cfa9c2 --- /dev/null +++ b/app/Admin/Resources/ConfigOptionResource/Pages/ListConfigOptions.php @@ -0,0 +1,19 @@ +schema([ + Forms\Components\TextInput::make('code') + ->label('Code') + ->required() + ->maxLength(255) + ->unique(static::getModel(), 'code', ignoreRecord: true) + ->placeholder('Enter the code of the coupon'), + + Forms\Components\TextInput::make('value') + ->label('Value') + ->required() + ->numeric() + ->minValue(0) + ->maxValue(fn (Get $get) => $get('type') === 'percentage' ? 100 : null) + ->mask(RawJs::make( + <<<'JS' + $money($input, '.', '', 2) + JS + )) + ->hidden(fn (Get $get) => $get('type') === 'free_setup') + ->suffix(fn (Get $get) => $get('type') === 'percentage' ? '%' : config('settings.default_currency')) + ->placeholder('Enter the value of the coupon'), + + Forms\Components\Select::make('type') + ->label('Type') + ->required() + ->default('percentage') + ->live() + ->options([ + 'percentage' => 'Percentage', + 'fixed' => 'Fixed amount', + 'free_setup' => 'Free setup', + ]) + ->placeholder('Select the type of the coupon'), + + Forms\Components\TextInput::make('recurring') + ->label('Recurring') + ->numeric() + ->nullable() + ->minValue(0) + ->hidden(fn (Get $get) => $get('type') === 'free_setup') + ->placeholder('How many billing cycles the discount will be applied') + ->helperText('Enter 0 to apply it to all billing cycles, 1 to apply it only to the first billing cycle, etc.'), + + Forms\Components\TextInput::make('max_uses') + ->label('Max Uses') + ->numeric() + ->minValue(0) + ->placeholder('Enter the maximum number of total uses of the coupon'), + + Forms\Components\TextInput::make('max_uses_per_user') + ->label('Max Uses Per User') + ->numeric() + ->minValue(0) + ->placeholder('Enter the maximum number of uses per user'), + + Forms\Components\DatePicker::make('starts_at') + ->label('Starts At'), + + Forms\Components\DatePicker::make('expires_at') + ->label('Expires At'), + + Forms\Components\Select::make('products') + ->label('Products') + ->relationship('products', 'name') + ->multiple() + ->preload() + ->placeholder('Select the products that this coupon applies to') + ->hint('Leave empty to apply the coupon to all products'), + ]); + } + + public static function table(Table $table): Table + { + return $table + ->columns([ + Tables\Columns\TextColumn::make('code')->searchable(), + Tables\Columns\TextColumn::make('value')->searchable()->formatStateUsing(fn ($record) => $record->value . ($record->type === 'percentage' ? '%' : config('settings.default_currency'))), + ]) + ->filters([ + // + ]) + ->actions([ + Tables\Actions\EditAction::make(), + ]) + ->bulkActions([ + Tables\Actions\BulkActionGroup::make([ + Tables\Actions\DeleteBulkAction::make(), + ]), + ]); + } + + public static function getRelations(): array + { + return [ + RelationManagers\ServicesRelationManager::class, + ]; + } + + public static function getPages(): array + { + return [ + 'index' => Pages\ListCoupons::route('/'), + 'create' => Pages\CreateCoupon::route('/create'), + 'edit' => Pages\EditCoupon::route('/{record}/edit'), + ]; + } +} diff --git a/app/Admin/Resources/CouponResource/Pages/CreateCoupon.php b/app/Admin/Resources/CouponResource/Pages/CreateCoupon.php new file mode 100644 index 000000000..f8216780e --- /dev/null +++ b/app/Admin/Resources/CouponResource/Pages/CreateCoupon.php @@ -0,0 +1,11 @@ +recordTitleAttribute('id') + ->columns([ + Tables\Columns\TextColumn::make('id'), + Tables\Columns\TextColumn::make('order.user.name')->label('User'), + Tables\Columns\TextColumn::make('product.name')->label('Product'), + ]) + ->filters([ + // + ]) + ->headerActions([]) + ->actions([ + Tables\Actions\Action::make('view') + ->label('View') + ->url(fn ($record) => ServiceResource::getUrl('edit', ['record' => $record])), + ]) + ->bulkActions([ + Tables\Actions\BulkActionGroup::make([ + Tables\Actions\DeleteBulkAction::make(), + ]), + ]); + } +} diff --git a/app/Admin/Resources/CurrencyResource.php b/app/Admin/Resources/CurrencyResource.php new file mode 100644 index 000000000..07bb8ca21 --- /dev/null +++ b/app/Admin/Resources/CurrencyResource.php @@ -0,0 +1,99 @@ +schema([ + Forms\Components\TextInput::make('code') + ->label('Code') + ->required() + ->maxLength(3) + ->disabledOn('edit') + ->unique(static::getModel(), 'code', ignoreRecord: true) + ->placeholder('Enter the currency code'), + Forms\Components\TextInput::make('prefix') + ->label('Prefix') + ->maxLength(10) + ->placeholder('Enter the currency prefix'), + Forms\Components\TextInput::make('suffix') + ->label('Suffix') + ->maxLength(10) + ->placeholder('Enter the currency suffix'), + Forms\Components\Select::make('format') + ->label('Format') + ->options([ + '1.000,00' => '1.000,00', + '1,000.00' => '1,000.00', + '1 000,00' => '1 000,00', + '1 000.00' => '1 000.00', + ]) + ->default('1.000,00'), + ]); + } + + public static function table(Table $table): Table + { + return $table + ->columns([ + Tables\Columns\TextColumn::make('code') + ->label('Code') + ->searchable() + ->sortable(), + Tables\Columns\TextColumn::make('prefix') + ->label('Prefix') + ->searchable() + ->sortable(), + Tables\Columns\TextColumn::make('suffix') + ->label('Suffix') + ->searchable() + ->sortable(), + ]) + ->filters([ + // + ]) + ->actions([ + Tables\Actions\EditAction::make(), + ]) + ->bulkActions([ + Tables\Actions\BulkActionGroup::make([ + Tables\Actions\DeleteBulkAction::make(), + ]), + ]); + } + + public static function getRelations(): array + { + return [ + // + ]; + } + + public static function getPages(): array + { + return [ + 'index' => Pages\ListCurrencies::route('/'), + 'create' => Pages\CreateCurrency::route('/create'), + 'edit' => Pages\EditCurrency::route('/{record}/edit'), + ]; + } +} diff --git a/app/Admin/Resources/CurrencyResource/Pages/CreateCurrency.php b/app/Admin/Resources/CurrencyResource/Pages/CreateCurrency.php new file mode 100644 index 000000000..f99aa7554 --- /dev/null +++ b/app/Admin/Resources/CurrencyResource/Pages/CreateCurrency.php @@ -0,0 +1,11 @@ +record->code) { + return []; + } + + return [ + Actions\DeleteAction::make(), + ]; + } +} diff --git a/app/Admin/Resources/CurrencyResource/Pages/ListCurrencies.php b/app/Admin/Resources/CurrencyResource/Pages/ListCurrencies.php new file mode 100644 index 000000000..54e325d9d --- /dev/null +++ b/app/Admin/Resources/CurrencyResource/Pages/ListCurrencies.php @@ -0,0 +1,19 @@ +schema([ + Forms\Components\TextInput::make('name') + ->required() + ->maxLength(255), + Forms\Components\TextInput::make('key')->required(), + Forms\Components\Select::make('model')->options([ + 'App\Models\User' => 'User', + ])->required(), + Forms\Components\Select::make('type')->options([ + 'string' => 'Short Text', + 'text' => 'Long Text', + 'number' => 'Number', + 'select' => 'Select', + 'checkbox' => 'Checkbox', + 'radio' => 'Radio', + 'date' => 'Date', + ])->required(), + Forms\Components\Textarea::make('description')->nullable()->columnSpanFull()->rows(2), + Forms\Components\TagsInput::make('allowed_values')->nullable(), + Forms\Components\TextInput::make('validation')->nullable(), + + Forms\Components\Section::make() + ->columns([ + 'sm' => 1, + 'md' => 3, + ]) + ->schema([ + Forms\Components\Toggle::make('non_editable')->default(false), + Forms\Components\Toggle::make('required')->default(false), + Forms\Components\Toggle::make('show_on_invoice')->default(false), + ]), + ]); + } + + public static function table(Table $table): Table + { + return $table + ->columns([ + Tables\Columns\TextColumn::make('name')->label('Property') + ->description(fn (CustomProperty $record): string => mb_strimwidth(($record->description ?? ''), + 0, + 75, + '...' + )), + Tables\Columns\TextColumn::make('key'), + Tables\Columns\TextColumn::make('type')->formatStateUsing(fn ($state) => str($state)->title()), + Tables\Columns\ToggleColumn::make('non_editable'), + Tables\Columns\ToggleColumn::make('required'), + Tables\Columns\ToggleColumn::make('show_on_invoice'), + ]) + ->filters([ + // + ]) + ->actions([ + Tables\Actions\EditAction::make(), + ]) + ->bulkActions([ + Tables\Actions\BulkActionGroup::make([ + Tables\Actions\DeleteBulkAction::make(), + ]), + ])->defaultGroup('model'); + } + + public static function getRelations(): array + { + return [ + // + ]; + } + + public static function getPages(): array + { + return [ + 'index' => Pages\ListCustomProperty::route('/'), + 'create' => Pages\CreateCustomProperty::route('/create'), + 'edit' => Pages\EditCustomProperty::route('/{record}/edit'), + ]; + } +} diff --git a/app/Admin/Resources/CustomPropertyResource/Pages/CreateCustomProperty.php b/app/Admin/Resources/CustomPropertyResource/Pages/CreateCustomProperty.php new file mode 100644 index 000000000..c258e0bc4 --- /dev/null +++ b/app/Admin/Resources/CustomPropertyResource/Pages/CreateCustomProperty.php @@ -0,0 +1,11 @@ +schema([ + Infolists\Components\TextEntry::make('subject') + ->size(Components\TextEntry\TextEntrySize::Medium) + ->label('Subject'), + Infolists\Components\TextEntry::make('to') + ->size(Components\TextEntry\TextEntrySize::Medium) + ->label('To'), + Infolists\Components\TextEntry::make('sent_at') + ->size(Components\TextEntry\TextEntrySize::Medium) + ->date() + ->hidden(fn ($state) => $state === null) + ->label('Sent At'), + Infolists\Components\TextEntry::make('status') + ->badge() + ->color(fn (EmailLog $record) => match ($record->status) { + 'pending' => 'gray', + 'sent' => 'success', + 'failed' => 'danger', + }) + ->formatStateUsing(fn (string $state) => ucfirst($state)) + ->label('Status'), + + Infolists\Components\TextEntry::make('error') + ->columnSpanFull() + ->size(Components\TextEntry\TextEntrySize::Medium) + ->hidden(fn ($state) => $state === null) + ->label('Error'), + View::make('admin.infolists.components.iframe')->columnSpanFull(), + ]); + } + + public static function table(Table $table): Table + { + return $table + ->columns([ + Tables\Columns\TextColumn::make('subject') + ->searchable(), + Tables\Columns\TextColumn::make('to') + ->searchable(), + Tables\Columns\TextColumn::make('sent_at') + ->dateTime() + ->sortable(), + Tables\Columns\TextColumn::make('status') + ->badge() + ->color(fn (EmailLog $record) => match ($record->status) { + 'pending' => 'gray', + 'sent' => 'success', + 'failed' => 'danger', + }) + ->formatStateUsing(fn (string $state) => ucfirst($state)) + ->searchable(), + ]) + ->defaultSort(function (Builder $query): Builder { + return $query + ->orderBy('id', 'desc'); + }) + ->filters([ + // + ]) + ->actions([ + Tables\Actions\ViewAction::make(), + ]); + } + + public static function getRelations(): array + { + return [ + // + ]; + } + + public static function getPages(): array + { + return [ + 'index' => Pages\ListEmailLogs::route('/'), + 'view' => Pages\ViewEmailLog::route('/{record}'), + ]; + } +} diff --git a/app/Admin/Resources/EmailLogResource/Pages/ListEmailLogs.php b/app/Admin/Resources/EmailLogResource/Pages/ListEmailLogs.php new file mode 100644 index 000000000..413685d62 --- /dev/null +++ b/app/Admin/Resources/EmailLogResource/Pages/ListEmailLogs.php @@ -0,0 +1,11 @@ +schema([ + Forms\Components\TextInput::make('key') + ->required() + ->disabledOn('edit') + ->maxLength(255), + Forms\Components\TextInput::make('subject') + ->required() + ->maxLength(255), + Forms\Components\Toggle::make('enabled') + ->required(), + Forms\Components\MarkdownEditor::make('body') + ->hint('Use either Markdown or HTML to compose the email body.') + ->disableAllToolbarButtons() + ->required() + ->columnSpanFull(), + Forms\Components\TagsInput::make('cc') + ->placeholder('mail@example.com') + ->nestedRecursiveRules(['required', 'email']) + ->columnSpanFull(), + Forms\Components\TagsInput::make('bcc') + ->nestedRecursiveRules(['required', 'email']) + ->placeholder('mail@example.com') + ->columnSpanFull(), + ]); + } + + public static function table(Table $table): Table + { + return $table + ->columns([ + Tables\Columns\TextColumn::make('subject') + ->searchable(), + Tables\Columns\IconColumn::make('enabled') + ->boolean(), + ]) + ->filters([ + // + ]) + ->actions([ + Tables\Actions\EditAction::make(), + ]); + } + + public static function getRelations(): array + { + return [ + // + ]; + } + + public static function getPages(): array + { + return [ + 'index' => Pages\ListEmailTemplates::route('/'), + 'create' => Pages\CreateEmailTemplate::route('/create'), + 'edit' => Pages\EditEmailTemplate::route('/{record}/edit'), + ]; + } +} diff --git a/app/Admin/Resources/EmailTemplateResource/Pages/CreateEmailTemplate.php b/app/Admin/Resources/EmailTemplateResource/Pages/CreateEmailTemplate.php new file mode 100644 index 000000000..f62941431 --- /dev/null +++ b/app/Admin/Resources/EmailTemplateResource/Pages/CreateEmailTemplate.php @@ -0,0 +1,11 @@ +where('type', 'exception'); + } + + public static function table(Table $table): Table + { + return $table + ->columns([ + Tables\Columns\TextColumn::make('message') + ->state(function (DebugLog $record) { + return $record->context['message'] ?? null; + }) + ->toggleable(), + Tables\Columns\TextColumn::make('file') + ->state(function (DebugLog $record) { + return $record->context['file'] ?? null; + }) + ->toggleable(), + Tables\Columns\TextColumn::make('line') + ->state(function (DebugLog $record) { + return $record->context['line'] ?? null; + }) + ->toggleable(), + Tables\Columns\TextColumn::make('created_at') + ->searchable() + ->toggleable(isToggledHiddenByDefault: true) + ->sortable(), + ]) + ->actions([ + Tables\Actions\ViewAction::make(), + ]) + ->bulkActions([ + Tables\Actions\BulkActionGroup::make([ + Tables\Actions\DeleteBulkAction::make(), + ]), + ]) + ->defaultSort('created_at', 'desc'); + } + + public static function shouldRegisterNavigation(): bool + { + return config('settings.debug', false); + } + + public static function infolist(Infolist $infolist): Infolist + { + return $infolist + ->columns(1) + ->schema([ + Infolists\Components\TextEntry::make('message') + ->state(function (DebugLog $record) { + return $record->context['message'] ?? null; + }), + Infolists\Components\TextEntry::make('file') + ->state(function (DebugLog $record) { + return $record->context['file'] ?? null; + }), + Infolists\Components\TextEntry::make('line') + ->state(function (DebugLog $record) { + return $record->context['line'] ?? null; + }), + + Infolists\Components\TextEntry::make('trace') + ->label('Trace') + ->state(function (DebugLog $record) { + return $record->context['trace'] ?? null; + }), + ]); + } + + public static function getPages(): array + { + return [ + 'index' => Pages\ListErrorLogs::route('/'), + ]; + } +} diff --git a/app/Admin/Resources/ErrorLogResource/Pages/ListErrorLogs.php b/app/Admin/Resources/ErrorLogResource/Pages/ListErrorLogs.php new file mode 100644 index 000000000..751271ea5 --- /dev/null +++ b/app/Admin/Resources/ErrorLogResource/Pages/ListErrorLogs.php @@ -0,0 +1,11 @@ +name; + } + + public static function form(Form $form): Form + { + return $form + ->schema([ + Toggle::make('enabled'), + Section::make('Extension Settings') + ->description('Specific settings for the selected extension') + ->schema([ + Grid::make()->schema(fn (Get $get) => ExtensionHelper::getConfigAsInputs('other', $get('extension'), $get('settings')))->key('settings'), + ]), + ]); + } + + public static function table(Table $table): Table + { + return $table + ->columns([ + Tables\Columns\TextColumn::make('name') + ->searchable() + ->sortable(), + Tables\Columns\TextColumn::make('type') + ->searchable() + ->sortable(), + Tables\Columns\IconColumn::make('enabled') + ->label('Enabled') + ->boolean() + ->sortable(), + ]) + ->filters([ + // + ]) + ->actions([ + Tables\Actions\EditAction::make(), + ]); + } + + public static function getEloquentQuery(): Builder + { + // Read whole base_path('extensions') directory and create a new Extension model for each extension (if it doesn't exist) + foreach (scandir(base_path('extensions')) as $extension) { + if (in_array($extension, ['.', '..', 'Gateways', 'Servers'])) { + continue; + } + + $type = strtolower($extension); + // Remove the 's' from end of the type + $type = substr($type, 0, -1); + + foreach (scandir(base_path('extensions/' . $extension)) as $extension) { + if (in_array($extension, ['.', '..'])) { + continue; + } + + Extension::firstOrCreate([ + 'extension' => $extension, + 'type' => $type, + 'name' => $extension, + ]); + } + } + + return parent::getEloquentQuery()->whereNotIn('type', ['gateway', 'server']); + } + + public static function getPages(): array + { + return [ + 'index' => Pages\ListExtensions::route('/'), + 'edit' => Pages\EditExtension::route('/{record}/edit'), + ]; + } +} diff --git a/app/Admin/Resources/ExtensionResource/Pages/EditExtension.php b/app/Admin/Resources/ExtensionResource/Pages/EditExtension.php new file mode 100644 index 000000000..d3ca0f0fe --- /dev/null +++ b/app/Admin/Resources/ExtensionResource/Pages/EditExtension.php @@ -0,0 +1,66 @@ +record->settings as $setting) { + $data['settings'][$setting->key] = $setting->value; + } + + return $data; + } + + protected function getRedirectUrl(): string + { + return $this->getResource()::getUrl('edit', ['record' => $this->record]); + } + + protected function handleRecordUpdate(Model $record, array $data): Model + { + // is the extension being enabled or disabled? + if ($record->enabled != $data['enabled']) { + // if the extension is being enabled, we need to run the extension's setup method + if ($data['enabled']) { + ExtensionHelper::call($record, 'enabled', [$record], mayFail: true); + } else { + ExtensionHelper::call($record, 'disabled', [$record], mayFail: true); + } + } + + $record->update(Arr::except($data, ['settings'])); + + if (!isset($data['settings'])) { + return $record; + } + + $config = ExtensionHelper::getConfig($record->type, $record->extension); + + foreach ($config as $option) { + $record->settings()->updateOrCreate([ + 'key' => $option['name'], + 'settingable_id' => $record->id, + 'settingable_type' => $record->getMorphClass(), + ], [ + 'type' => $option['database_type'] ?? 'string', + 'value' => isset($data['settings'][$option['name']]) ? (is_array($data['settings'][$option['name']]) ? json_encode($data['settings'][$option['name']]) : $data['settings'][$option['name']]) : null, + 'encrypted' => $option['encrypted'] ?? false, + ]); + } + + ExtensionHelper::call($record, 'updated', [$record], mayFail: true); + + // Maybe the extension changed the record, so we need to refresh it + return $record->refresh(); + } +} diff --git a/app/Admin/Resources/ExtensionResource/Pages/ListExtensions.php b/app/Admin/Resources/ExtensionResource/Pages/ListExtensions.php new file mode 100644 index 000000000..a8c376f23 --- /dev/null +++ b/app/Admin/Resources/ExtensionResource/Pages/ListExtensions.php @@ -0,0 +1,11 @@ +columns([ + Tables\Columns\TextColumn::make('uuid')->toggleable(isToggledHiddenByDefault: true), + Tables\Columns\TextColumn::make('payload')->formatStateUsing(function ($state) { + $state = json_decode($state); + + // List displayName + return $state->displayName; + }), + Tables\Columns\TextColumn::make('exception')->formatStateUsing(function ($state) { + return explode("\n", $state)[0]; + })->wrap()->tooltip(function ($state) { + return $state; + })->limit(200), + Tables\Columns\TextColumn::make('failed_at'), + ]) + ->poll() + ->actions([ + Tables\Actions\Action::make('retry') + ->label('Retry') + ->requiresConfirmation() + ->action(function (FailedJob $record): void { + Artisan::call("queue:retry {$record->uuid}"); + Notification::make() + ->title("The job with uuid '{$record->uuid}' has been pushed back onto the queue.") + ->success() + ->send(); + }), + Tables\Actions\Action::make('delete') + ->label('Mark as Resolved') + ->requiresConfirmation() + ->color('danger') + ->action(function (FailedJob $failedJob) { + $failedJob->delete(); + }), + ]) + ->bulkActions([ + BulkAction::make('retry') + ->label('Retry') + ->requiresConfirmation() + ->action(function (Collection $records): void { + foreach ($records as $record) { + try { + Artisan::call("queue:retry {$record->uuid}"); + } catch (\Exception $e) { + Notification::make() + ->title($e->getMessage()) + ->warning() + ->send(); + } + } + Notification::make() + ->title("{$records->count()} jobs have been pushed back onto the queue.") + ->success() + ->send(); + }), + ]) + ->defaultSort('failed_at', 'desc') + ->filters([ + + ]); + } + + public static function canCreate(): bool + { + return false; + } + + public static function getPages(): array + { + return [ + 'index' => Pages\ListFailedJobs::route('/'), + ]; + } +} diff --git a/app/Admin/Resources/FailedJobResource/Pages/ListFailedJobs.php b/app/Admin/Resources/FailedJobResource/Pages/ListFailedJobs.php new file mode 100644 index 000000000..3c4273a0a --- /dev/null +++ b/app/Admin/Resources/FailedJobResource/Pages/ListFailedJobs.php @@ -0,0 +1,49 @@ +count() > 0) { + $firstJob = DB::table('jobs')->orderBy('available_at', 'asc')->first(); + $firstJobAvailableAt = \Carbon\Carbon::parse($firstJob->available_at); + $now = \Carbon\Carbon::now(); + $diffInMinutes = $firstJobAvailableAt->diffInMinutes($now); + if ($diffInMinutes > 5) { + Notification::make() + ->title('Whoops!') + ->body('There are ' . DB::table('jobs')->count() . " jobs in the queue.\nThis could mean that your jobs are not being processed correctly.") + ->danger() + ->actions([ + Action::make('view') + ->label('Browse Documentation') + ->button() + ->url('https://paymenter.org/docs/installation/install#creating-cronjob-and-service', shouldOpenInNewTab: true), + ]) + ->send(); + } + } + + parent::mount(); + } +} diff --git a/app/Admin/Resources/GatewayResource.php b/app/Admin/Resources/GatewayResource.php new file mode 100644 index 000000000..ca719b65e --- /dev/null +++ b/app/Admin/Resources/GatewayResource.php @@ -0,0 +1,110 @@ +name; + } + + public static function form(Form $form): Form + { + $gateways = \App\Helpers\ExtensionHelper::getExtensions('gateway'); + + return $form + ->schema([ + Forms\Components\TextInput::make('name') + ->label('Name') + ->required() + ->maxLength(255) + ->unique(static::getModel(), 'name', ignoreRecord: true) + ->placeholder('Enter the name of the gateway'), + Forms\Components\Select::make('extension') + ->label('Gateway') + ->required() + ->searchable() + ->unique(static::getModel(), 'extension', ignoreRecord: true) + ->options(array_combine( + array_column($gateways, 'name'), + array_column($gateways, 'name') + )) + ->live(onBlur: true) + ->afterStateUpdated(fn (Select $component) => $component + ->getContainer() + ->getComponent('settings') + ->getChildComponentContainer() + ->fill()) + ->placeholder('Select the type of the gateway'), + Section::make('Gateway Settings') + ->description('Specific settings for the selected gateway') + ->schema([ + Grid::make()->schema(fn (Get $get) => ExtensionHelper::getConfigAsInputs('gateway', $get('extension'), $get('settings')))->key('settings'), + ]), + ]); + } + + public static function table(Table $table): Table + { + return $table + ->columns([ + Tables\Columns\TextColumn::make('name')->searchable(), + ]) + ->filters([ + // + ]) + ->actions([ + Tables\Actions\EditAction::make(), + ]) + ->bulkActions([ + Tables\Actions\BulkActionGroup::make([ + Tables\Actions\DeleteBulkAction::make(), + ]), + ]); + } + + public static function getRelations(): array + { + return [ + // + ]; + } + + public static function getPages(): array + { + return [ + 'index' => Pages\ListGateways::route('/'), + 'create' => Pages\CreateGateway::route('/create'), + 'edit' => Pages\EditGateway::route('/{record}/edit'), + ]; + } +} diff --git a/app/Admin/Resources/GatewayResource/Pages/CreateGateway.php b/app/Admin/Resources/GatewayResource/Pages/CreateGateway.php new file mode 100644 index 000000000..8f276421c --- /dev/null +++ b/app/Admin/Resources/GatewayResource/Pages/CreateGateway.php @@ -0,0 +1,45 @@ + $value) { + if (is_null($value)) { + continue; + } + $record->settings()->updateOrCreate([ + 'key' => $key, + ], [ + 'value' => $value, + ]); + } + + ExtensionHelper::call($record, 'enabled', [$record], mayFail: true); + + return $record; + } +} diff --git a/app/Admin/Resources/GatewayResource/Pages/EditGateway.php b/app/Admin/Resources/GatewayResource/Pages/EditGateway.php new file mode 100644 index 000000000..8addd06d4 --- /dev/null +++ b/app/Admin/Resources/GatewayResource/Pages/EditGateway.php @@ -0,0 +1,59 @@ +before(fn ($record) => ExtensionHelper::call($record, 'disabled', [$record], mayFail: true)), + ]; + } + + protected function mutateFormDataBeforeFill(array $data): array + { + foreach ($this->record->settings as $setting) { + $data['settings'][$setting->key] = $setting->value; + } + + return $data; + } + + protected function handleRecordUpdate(Model $record, array $data): Model + { + $record->update(Arr::except($data, ['settings'])); + + if (!isset($data['settings'])) { + return $record; + } + + $config = ExtensionHelper::getConfig($record->type, $record->extension); + + foreach ($config as $option) { + $record->settings()->updateOrCreate([ + 'key' => $option['name'], + 'settingable_id' => $record->id, + 'settingable_type' => $record->getMorphClass(), + ], [ + 'type' => $option['database_type'] ?? 'string', + 'value' => isset($data['settings'][$option['name']]) ? (is_array($data['settings'][$option['name']]) ? json_encode($data['settings'][$option['name']]) : $data['settings'][$option['name']]) : null, + 'encrypted' => $option['encrypted'] ?? false, + ]); + } + + ExtensionHelper::call($record, 'updated', [$record], mayFail: true); + + // Maybe the extension changed the record, so we need to refresh it + return $record->refresh(); + } +} diff --git a/app/Admin/Resources/GatewayResource/Pages/ListGateways.php b/app/Admin/Resources/GatewayResource/Pages/ListGateways.php new file mode 100644 index 000000000..be9ab2167 --- /dev/null +++ b/app/Admin/Resources/GatewayResource/Pages/ListGateways.php @@ -0,0 +1,19 @@ +where('type', 'http'); + } + + public static function table(Table $table): Table + { + return $table + ->columns([ + Tables\Columns\TextColumn::make('method') + ->state(function (DebugLog $record) { + return $record->context['method'] ?? null; + }) + ->toggleable(), + Tables\Columns\TextColumn::make('url') + ->state(function (DebugLog $record) { + return $record->context['url'] ?? null; + }) + ->toggleable(), + Tables\Columns\TextColumn::make('status') + ->state(function (DebugLog $record) { + return $record->context['response_status'] ?? null; + }) + ->toggleable(), + Tables\Columns\TextColumn::make('created_at') + ->searchable() + ->toggleable(isToggledHiddenByDefault: true) + ->sortable(), + ]) + ->actions([ + Tables\Actions\ViewAction::make(), + ]) + ->bulkActions([ + Tables\Actions\BulkActionGroup::make([ + Tables\Actions\DeleteBulkAction::make(), + ]), + ]) + ->defaultSort('created_at', 'desc'); + } + + public static function shouldRegisterNavigation(): bool + { + return config('settings.debug', false); + } + + public static function infolist(Infolist $infolist): Infolist + { + return $infolist + ->schema([ + Infolists\Components\TextEntry::make('method') + ->state(function (DebugLog $record) { + return $record->context['method'] ?? null; + }), + Infolists\Components\TextEntry::make('url') + ->state(function (DebugLog $record) { + return $record->context['url'] ?? null; + }), + + Infolists\Components\TextEntry::make('status') + ->state(function (DebugLog $record) { + return $record->context['response_status'] ?? null; + }), + + Infolists\Components\ViewEntry::make('request') + ->label('Request') + ->view('admin.infolists.components.json') + ->state(function (DebugLog $record) { + return $record->context['payload'] ?? null; + })->columnSpanFull(), + + Infolists\Components\ViewEntry::make('request_headers') + ->label('Request Headers') + ->view('admin.infolists.components.json') + ->state(function (DebugLog $record) { + return $record->context['headers'] ?? null; + })->columnSpanFull(), + + Infolists\Components\ViewEntry::make('response_headers') + ->label('Response Headers') + ->view('admin.infolists.components.json') + ->state(function (DebugLog $record) { + return $record->context['response_headers'] ?? null; + })->columnSpanFull(), + + Infolists\Components\ViewEntry::make('response') + ->label('Response') + ->view('admin.infolists.components.json') + ->state(function (DebugLog $record) { + return $record->context['response'] ?? null; + })->columnSpanFull(), + + ]); + } + + public static function getPages(): array + { + return [ + 'index' => Pages\ListHttpLogs::route('/'), + ]; + } +} diff --git a/app/Admin/Resources/HttpLogResource/Pages/ListHttpLogs.php b/app/Admin/Resources/HttpLogResource/Pages/ListHttpLogs.php new file mode 100644 index 000000000..b0fda72b1 --- /dev/null +++ b/app/Admin/Resources/HttpLogResource/Pages/ListHttpLogs.php @@ -0,0 +1,11 @@ +count() ?: null; + } + + public static function getNavigationBadgeColor(): ?string + { + return 'warning'; + } + + public static ?string $navigationGroup = 'Administration'; + + public static function form(Form $form): Form + { + return $form + ->schema([ + UserComponent::make('user_id'), + Forms\Components\TextInput::make('number') + ->label('Invoice Number') + ->helperText('The invoice number will be generated automatically') + ->disabled(), + Forms\Components\DatePicker::make('created_at') + ->label('Issued At') + ->required() + ->default(now()) + ->placeholder('Select the date and time the invoice was issued'), + Forms\Components\DatePicker::make('due_at') + ->label('Due At') + ->required() + ->default(now()->addDays(7)) + ->placeholder('Select the date and time the invoice is due'), + Forms\Components\Select::make('status') + ->label('Status') + ->required() + ->options([ + 'paid' => 'Paid', + 'pending' => 'Pending', + 'cancelled' => 'Cancelled', + ]) + ->default('pending') + ->placeholder('Select the status of the invoice'), + Forms\Components\Select::make('currency_code') + ->label('Currency') + ->required() + ->relationship('currency', 'code') + ->placeholder('Select the currency'), + Forms\Components\Toggle::make('send_email') + ->label('Send Email') + ->hiddenOn('edit') + ->default(true), + Forms\Components\Repeater::make('items') + ->relationship('items') + ->label('Items') + ->columnSpanFull() + ->columns(2) + ->schema([ + Forms\Components\TextInput::make('price') + ->label('Price') + // Grab invoice currency + ->prefix(fn (Get $get): ?string => Currency::where('code', $get('../../currency_code'))->first()?->prefix) + ->suffix(fn (Get $get): ?string => Currency::where('code', $get('../../currency_code'))->first()?->suffix) + ->required() + ->numeric() + ->mask(RawJs::make( + <<<'JS' + $money($input, '.', '', 2) + JS + )) + ->placeholder('Enter the price of the product'), + Forms\Components\TextInput::make('quantity') + ->label('Quantity') + ->required() + ->numeric() + ->placeholder('Enter the quantity of the product'), + Forms\Components\TextInput::make('description') + ->label('Description') + ->required() + ->hintAction( + Forms\Components\Actions\Action::make('View Service') + ->url(function (Get $get) { + return ServiceResource::getUrl('edit', ['record' => $get('reference_id')]); + }) + ->label('View Service') + ->hidden(fn (Get $get): bool => !in_array($get('reference_type'), [Service::class, ServiceUpgrade::class])) + ) + ->placeholder('Enter the description of the product'), + Forms\Components\Hidden::make('reference_type'), + Forms\Components\Hidden::make('reference_id'), + ]), + + ]); + } + + public static function table(Table $table): Table + { + return $table + ->columns([ + Tables\Columns\TextColumn::make('id') + ->label('ID') + ->searchable() + ->sortable(), + Tables\Columns\TextColumn::make('number') + ->searchable() + ->sortable(), + Tables\Columns\TextColumn::make('user.name') + ->label('User') + ->searchable(true, fn (Builder $query, string $search) => $query->whereHas('user', fn (Builder $query) => $query->where('first_name', 'like', "%$search%")->orWhere('last_name', 'like', "%$search%"))), + Tables\Columns\TextColumn::make('user.email') + ->label('Email') + ->searchable(), + Tables\Columns\TextColumn::make('status') + ->label('Status') + // Make first letter uppercase + ->formatStateUsing(fn (string $state): string => ucfirst($state)) + ->badge() + ->color(fn (string $state): string => match ($state) { + 'paid' => 'success', + 'pending' => 'warning', + default => 'danger', + }) + ->searchable() + ->sortable(), + Tables\Columns\TextColumn::make('created_at') + ->label('Issued At') + ->date() + ->searchable() + ->sortable(), + Tables\Columns\TextColumn::make('formattedTotal') + ->label('Total'), + Tables\Columns\TextColumn::make('formattedRemaining') + ->label('Remaining'), + ]) + ->defaultSort(function (Builder $query): Builder { + return $query + ->orderBy('id', 'desc'); + }) + ->filters([ + SelectFilter::make('status') + ->label('Status') + ->options([ + 'paid' => 'Paid', + 'pending' => 'Pending', + 'cancelled' => 'Cancelled', + ]), + ]) + ->actions([ + Tables\Actions\EditAction::make(), + ]) + ->bulkActions([ + Tables\Actions\BulkActionGroup::make([ + Tables\Actions\DeleteBulkAction::make(), + ]), + ]); + } + + public static function getRelations(): array + { + return [ + RelationManagers\TransactionsRelationManager::class, + ]; + } + + public static function getPages(): array + { + return [ + 'index' => Pages\ListInvoices::route('/'), + 'create' => Pages\CreateInvoice::route('/create'), + 'edit' => Pages\EditInvoice::route('/{record}/edit'), + ]; + } +} diff --git a/app/Admin/Resources/InvoiceResource/Pages/CreateInvoice.php b/app/Admin/Resources/InvoiceResource/Pages/CreateInvoice.php new file mode 100644 index 000000000..a63705de0 --- /dev/null +++ b/app/Admin/Resources/InvoiceResource/Pages/CreateInvoice.php @@ -0,0 +1,22 @@ +fill($data); + $invoice->send_create_email = $data['send_email'] ?? true; + $invoice->save(); + + return $invoice; + } +} diff --git a/app/Admin/Resources/InvoiceResource/Pages/EditInvoice.php b/app/Admin/Resources/InvoiceResource/Pages/EditInvoice.php new file mode 100644 index 000000000..77649902e --- /dev/null +++ b/app/Admin/Resources/InvoiceResource/Pages/EditInvoice.php @@ -0,0 +1,28 @@ +label('Download PDF') + ->action(function (Invoice $invoice) { + return response()->streamDownload(function () use ($invoice) { + echo PDF::generateInvoice($invoice)->stream(); + }, 'invoice-' . $invoice->number . '.pdf'); + }), + ]; + } +} diff --git a/app/Admin/Resources/InvoiceResource/Pages/ListInvoices.php b/app/Admin/Resources/InvoiceResource/Pages/ListInvoices.php new file mode 100644 index 000000000..7eb09c3d8 --- /dev/null +++ b/app/Admin/Resources/InvoiceResource/Pages/ListInvoices.php @@ -0,0 +1,19 @@ +schema([ + Forms\Components\Select::make('gateway.name') + ->label('Gateway') + ->relationship('gateway', 'name') + ->searchable() + ->preload() + ->placeholder('Select the gateway'), + Forms\Components\TextInput::make('transaction_id') + ->label('Transaction ID'), + Forms\Components\TextInput::make('amount') + ->label('Amount') + ->numeric() + ->mask(RawJs::make( + <<<'JS' + $money($input, '.', '', 2) + JS + )) + ->required(), + Forms\Components\TextInput::make('fee') + ->numeric() + ->mask(RawJs::make( + <<<'JS' + $money($input, '.', '', 2) + JS + )) + ->label('Fee'), + ]); + } + + public function table(Table $table): Table + { + return $table + ->recordTitleAttribute('transaction_id') + ->columns([ + Tables\Columns\TextColumn::make('gateway.name')->label('Gateway'), + Tables\Columns\TextColumn::make('transaction_id'), + Tables\Columns\TextColumn::make('formattedAmount')->label('Amount'), + Tables\Columns\TextColumn::make('formattedFee')->label('Fee'), + Tables\Columns\TextColumn::make('created_at'), + ]) + ->filters([ + // + ]) + ->headerActions([ + Tables\Actions\CreateAction::make(), + ]) + ->actions([ + Tables\Actions\DeleteAction::make(), + ]) + ->bulkActions([ + Tables\Actions\BulkActionGroup::make([ + Tables\Actions\DeleteBulkAction::make(), + ]), + ]); + } +} diff --git a/app/Admin/Resources/OauthClientResource.php b/app/Admin/Resources/OauthClientResource.php new file mode 100644 index 000000000..381e76505 --- /dev/null +++ b/app/Admin/Resources/OauthClientResource.php @@ -0,0 +1,103 @@ +schema([ + Forms\Components\TextInput::make('name') + ->required() + ->maxLength(255), + Forms\Components\TagsInput::make('redirect') + ->required() + ->separator(',') + ->columnSpanFull(), + Forms\Components\TextInput::make('secret') + ->disabled() + ->formatStateUsing(fn ($record) => $record?->secret) + ->hiddenOn(['create']) + ->suffixAction( + Action::make('copy') + ->icon('heroicon-s-clipboard-document-check') + ->action(function ($livewire, $state) { + $livewire->js( + 'window.navigator.clipboard.writeText("' . $state . '"); + $tooltip("' . __('Copied to clipboard') . '", { timeout: 1500 });' + ); + }) + ), + Forms\Components\TextInput::make('client_id') + ->disabled() + ->formatStateUsing(fn ($record) => $record?->id) + ->hiddenOn(['create']) + ->suffixAction( + Action::make('copy') + ->icon('heroicon-s-clipboard-document-check') + ->action(function ($livewire, $state) { + $livewire->js( + 'window.navigator.clipboard.writeText("' . $state . '"); + $tooltip("' . __('Copied to clipboard') . '", { timeout: 1500 });' + ); + }) + ), + ]); + } + + public static function table(Table $table): Table + { + return $table + ->columns([ + Tables\Columns\TextColumn::make('id') + ->label('ID'), + Tables\Columns\TextColumn::make('name') + ->searchable(), + ]) + ->filters([ + // + ]) + ->actions([ + Tables\Actions\EditAction::make(), + ]) + ->bulkActions([ + Tables\Actions\BulkActionGroup::make([ + Tables\Actions\DeleteBulkAction::make(), + ]), + ]); + } + + public static function getRelations(): array + { + return [ + // + ]; + } + + public static function getPages(): array + { + return [ + 'index' => Pages\ListOauthClients::route('/'), + 'create' => Pages\CreateOauthClient::route('/create'), + 'edit' => Pages\EditOauthClient::route('/{record}/edit'), + ]; + } +} diff --git a/app/Admin/Resources/OauthClientResource/Pages/CreateOauthClient.php b/app/Admin/Resources/OauthClientResource/Pages/CreateOauthClient.php new file mode 100644 index 000000000..32f7bc46a --- /dev/null +++ b/app/Admin/Resources/OauthClientResource/Pages/CreateOauthClient.php @@ -0,0 +1,41 @@ +getPublicKey()); + file_put_contents($privateKey, (string) $key); + + if (!windows_os()) { + chmod($publicKey, 0660); + chmod($privateKey, 0600); + } + } + + $clientRepository = new ClientRepository; + $record = $clientRepository->create(null, $data['name'], $data['redirect']); + + return $record; + } +} diff --git a/app/Admin/Resources/OauthClientResource/Pages/EditOauthClient.php b/app/Admin/Resources/OauthClientResource/Pages/EditOauthClient.php new file mode 100644 index 000000000..0b7ee5cec --- /dev/null +++ b/app/Admin/Resources/OauthClientResource/Pages/EditOauthClient.php @@ -0,0 +1,19 @@ +schema([ + UserComponent::make('user_id') + ->afterStateUpdated(function (Set $set, Get $get) { + // update all the services user_id + $set('services', collect($get('services'))->map(fn ($service) => array_merge($service, ['user_id' => $get('user_id')]))->toArray()); + }), + Forms\Components\Select::make('currency_code') + ->label('Currency') + ->required() + ->live() + ->afterStateUpdated(function (Set $set, Get $get) { + // update all the services currency_code + $set('services', collect($get('services'))->map(fn ($service) => array_merge($service, ['currency_code' => $get('currency_code')]))->toArray()); + }) + ->relationship('currency', 'code') + ->helperText('Does not convert the price, only displays the currency symbol') + ->placeholder('Select the currency'), + Forms\Components\Repeater::make('services') + ->relationship('services') + ->label('Services') + ->columnSpanFull() + ->columns(2) + ->schema([ + Forms\Components\Hidden::make('user_id') + ->default(fn (Get $get) => $get('../../user_id')), + Forms\Components\Hidden::make('currency_code') + ->default(fn (Get $get) => $get('../../currency_code')), + Forms\Components\Select::make('product_id') + ->label('Product') + ->required() + ->options(Product::all()->pluck('name', 'id')->toArray()) + ->searchable() + ->live() + ->afterStateUpdated(fn (Set $set) => $set('plan_id', null)) + ->placeholder('Select the product'), + Forms\Components\Select::make('plan_id') + ->label('Plan') + ->required() + ->relationship('plan', 'name', fn (Builder $query, Get $get) => $query->where('priceable_type', Product::class)->where('priceable_id', $get('product_id'))) + ->searchable() + ->preload() + ->live() + ->disabled(fn (Get $get) => (!$get('product_id') || !$get('../../currency_code'))) + ->afterStateUpdated(function (Set $set, Get $get) { + if (!$get('product_id') || !$get('plan_id') || !$get('../../currency_code')) { + return; + } + // Update the price when the plan changes + $plan = Product::find($get('product_id'))->plans->find($get('plan_id'))->prices->where('currency_code', $get('../../currency_code'))->first(); + if (!$plan) { + return; + } + $set('price', $plan->price); + }) + ->placeholder('Select the plan'), + Forms\Components\TextInput::make('quantity') + ->label('Quantity') + ->required() + ->placeholder('Enter the quantity'), + Forms\Components\TextInput::make('price') + ->suffix(fn (Component $component, Get $get) => $component->getRecord()?->currency->suffix ?? Currency::where('code', $get('../../currency_code'))->first()?->suffix) + ->prefix(fn (Component $component, Get $get) => $component->getRecord()?->currency->prefix ?? Currency::where('code', $get('../../currency_code'))->first()?->prefix) + ->label('Price') + ->required() + ->mask(RawJs::make( + <<<'JS' + $money($input, '.', '', 2) + JS + )) + ->placeholder('Enter the price'), + Forms\Components\TextInput::make('subscription_id') + ->label('Subscription ID') + ->nullable() + ->placeholder('Enter the subscription ID') + ->hintActions([ + Action::make('Cancel Subscription ID') + ->action(function (Component $component) { + if (ExtensionHelper::cancelSubscription($component->getRecord())) { + Notification::make('Subscription Cancelled') + ->title('The subscription has been successfully cancelled') + ->success() + ->send(); + } else { + Notification::make('Subscription Not Cancelled') + ->title('The subscription could not be cancelled') + ->error() + ->send(); + } + // Update the record to remove the subscription ID + $component->getRecord()->update(['subscription_id' => null]); + }) + ->requiresConfirmation() + ->label('Cancel Subscription') + ->hidden(fn (Component $component) => !$component->getRecord()?->subscription_id), + ]), + ]), + ]); + } + + public static function table(Table $table): Table + { + return $table + ->columns([ + Tables\Columns\TextColumn::make('id') + ->label('ID') + ->searchable() + ->sortable(), + Tables\Columns\TextColumn::make('user.name') + ->label('User') + ->searchable(query: fn (Builder $query, $search) => $query->whereHas('user', fn (Builder $query) => $query->where('first_name', 'like', "%$search%")->orWhere('last_name', 'like', "%$search%"))), + Tables\Columns\TextColumn::make('user.email') + ->label('Email') + ->searchable(), + Tables\Columns\TextColumn::make('currency.code') + ->label('Currency') + ->searchable() + ->sortable(), + Tables\Columns\TextColumn::make('formattedTotal') + ->label('Total'), + Tables\Columns\TextColumn::make('updated_at') + ->label('Updated At') + ->searchable() + ->sortable(), + ]) + ->defaultSort(function (Builder $query): Builder { + return $query + ->orderBy('id', 'desc'); + }) + ->filters([ + // + ]) + ->actions([ + Tables\Actions\EditAction::make(), + ]) + ->bulkActions([ + Tables\Actions\BulkActionGroup::make([ + Tables\Actions\DeleteBulkAction::make(), + ]), + ]); + } + + public static function getRelations(): array + { + return [ + RelationManagers\ServiceRelationManager::class, + ]; + } + + public static function getPages(): array + { + return [ + 'index' => Pages\ListOrders::route('/'), + 'create' => Pages\CreateOrder::route('/create'), + 'edit' => Pages\EditOrder::route('/{record}/edit'), + ]; + } +} diff --git a/app/Admin/Resources/OrderResource/Pages/CreateOrder.php b/app/Admin/Resources/OrderResource/Pages/CreateOrder.php new file mode 100644 index 000000000..582251336 --- /dev/null +++ b/app/Admin/Resources/OrderResource/Pages/CreateOrder.php @@ -0,0 +1,33 @@ + $this->record->user_id, + 'currency_code' => $this->record->currency_code, + 'due_at' => now()->addDays(7), + ]); + $invoice->save(); + + foreach ($this->record->services as $service) { + $invoice->items()->create([ + 'description' => $service->description, + 'price' => $service->price, + 'quantity' => $service->quantity, + 'reference_id' => $service->id, + 'reference_type' => get_class($service), + ]); + } + + } +} diff --git a/app/Admin/Resources/OrderResource/Pages/EditOrder.php b/app/Admin/Resources/OrderResource/Pages/EditOrder.php new file mode 100644 index 000000000..e0c821317 --- /dev/null +++ b/app/Admin/Resources/OrderResource/Pages/EditOrder.php @@ -0,0 +1,19 @@ +recordTitleAttribute('product.name') + ->columns([ + Tables\Columns\TextColumn::make('product.name'), + Tables\Columns\TextColumn::make('quantity'), + Tables\Columns\TextColumn::make('formattedPrice')->label('Price'), + ]) + ->filters([ + // + ]) + ->actions([ + Tables\Actions\ViewAction::make()->url(fn ($record) => ServiceResource::getUrl('edit', ['record' => $record])), + ]) + ->bulkActions([ + Tables\Actions\BulkActionGroup::make([ + Tables\Actions\DeleteBulkAction::make(), + ]), + ]); + } +} diff --git a/app/Admin/Resources/ProductResource.php b/app/Admin/Resources/ProductResource.php new file mode 100644 index 000000000..dfaa8c8a8 --- /dev/null +++ b/app/Admin/Resources/ProductResource.php @@ -0,0 +1,308 @@ +schema([ + Tabs::make('Tabs') + ->persistTabInQueryString() + ->tabs([ + Tabs\Tab::make('General') + ->columns(2) + ->schema([ + Forms\Components\TextInput::make('name') + ->required() + ->maxLength(255) + ->live(onBlur: true) + ->afterStateUpdated(function (Get $get, Set $set, ?string $old, ?string $state) { + if (($get('slug') ?? '') !== Str::slug($old)) { + return; + } + + $set('slug', Str::slug($state)); + }), + Forms\Components\TextInput::make('slug')->required()->unique(ignoreRecord: true), + Forms\Components\TextInput::make('stock')->integer()->nullable(), + Forms\Components\TextInput::make('per_user_limit')->integer()->nullable(), + Forms\Components\Select::make('allow_quantity')->options([ + 'disabled' => 'No', + 'separated' => 'Separated', + 'combined' => 'Combined', + ])->default('separated') + ->required(), + Forms\Components\Textarea::make('email_template') + ->hint('This snippet will be used in the email template.') + ->nullable(), + Forms\Components\Checkbox::make('hidden') + ->label('Hide product') + ->hint('Hide the product from the client area.'), + + Forms\Components\Textarea::make('description')->nullable()->columnSpanFull(), + Forms\Components\FileUpload::make('image')->label('Image')->nullable()->acceptedFileTypes(['image/*']), + Forms\Components\Select::make('category_id') + ->relationship('category', 'name') + ->searchable() + ->preload() + ->createOptionForm(fn (Form $form) => CategoryResource::form($form)) + ->required(), + ]), + Tabs\Tab::make('Pricing') + ->schema([self::plan()]), + + Tabs\Tab::make('Upgrades') + ->schema([ + // Select input for the products this product can upgrade to (hasmany relationship) + Forms\Components\Select::make('upgrades') + ->label('Upgrades') + ->relationship('upgrades', 'name', ignoreRecord: true) + ->multiple() + ->preload() + ->placeholder('Select the products that this product can upgrade to'), + ]), + + Tabs\Tab::make('Server') + ->schema([ + Forms\Components\Select::make('server_id') + ->relationship('server', 'name') + ->searchable() + ->preload() + ->hintAction( + Action::make('refresh') + ->label('Refresh') + ->action(fn () => Cache::set('product_config', null, 0)) + ->hidden(fn (Get $get) => $get('server_id') === null) + ) + ->live(), + + Grid::make('settings') + ->hidden(fn (Get $get) => $get('server_id') === null) + ->columns(2) + ->schema( + function (Get $get) { + $server = $get('server_id'); + if ($server == null) { + return []; + } + $settings = []; + + try { + foreach (ExtensionHelper::getProductConfigOnce(Server::findOrFail($server), $get('settings')) as $setting) { + // Easier to use dot notation for settings + $setting['name'] = 'settings.' . $setting['name']; + $settings[] = FilamentInput::convert($setting); + } + } catch (\Exception $e) { + $settings[] = Forms\Components\Placeholder::make('error')->content($e->getMessage()); + } + + return $settings; + } + ), + + ]), + ]), + ])->columns(1); + } + + public static function plan() + { + return Forms\Components\Repeater::make('plan') + ->addActionLabel('Add new plan') + ->relationship('plans') + ->name('name') + ->reorderable() + ->cloneable() + ->collapsible() + ->collapsed() + ->orderColumn() + ->defaultItems(1) + ->minItems(1) + ->columns(2) + ->deleteAction(function (Action $action) { + $action->before(function (?Product $record, $state, Action $action, array $arguments) { + if (!$record) { + return; + } + $key = $arguments['item']; + if (!isset($state[$key]['id'])) { + return; + } + $plan = $record->plans()->find($state[$key]['id']); + if ($plan->services()->count() > 0) { + Notification::make() + ->title('Whoops!') + ->body('You cannot delete this plan because it is being used by one or more services.') + ->danger() + ->send(); + $action->cancel(); + } + }); + }) + ->itemLabel(fn (array $state) => $state['name']) + ->schema([ + Forms\Components\TextInput::make('name') + ->required() + ->live(onBlur: true) + ->maxLength(255), + Forms\Components\Select::make('type') + ->options([ + 'free' => 'Free', + 'one-time' => 'One Time', + 'recurring' => 'Recurring', + ]) + ->required() + ->live(debounce: 300) + ->afterStateUpdated(function (Get $get, Set $set, ?string $old, ?string $state) { + if ($state === 'free') { + $set('every', null); + $set('price', 0); + } + }) + ->placeholder('Select the type of the price') + ->default('free'), + + Forms\Components\TextInput::make('billing_period') + ->required() + ->label('Time Interval') + ->default(1) + ->hidden(fn (Get $get) => $get('type') !== 'recurring'), + + Forms\Components\Select::make('billing_unit') + ->options([ + 'day' => 'Day', + 'week' => 'Week', + 'month' => 'Month', + 'year' => 'Year', + ]) + ->label('Billing period') + ->required() + ->default('month') + ->hidden(fn (Get $get) => $get('type') !== 'recurring'), + Forms\Components\Repeater::make('pricing') + ->hidden(fn (Get $get) => $get('type') === 'free') + ->columns(3) + ->addActionLabel('Add new price') + ->reorderable(false) + ->relationship('prices') + ->columnSpanFull() + ->maxItems(Currency::count()) + ->defaultItems(1) + ->itemLabel(fn (array $state) => $state['currency_code']) + ->schema([ + Forms\Components\Select::make('currency_code') + ->options(function (Get $get, ?string $state) { + $pricing = collect($get('../../pricing'))->pluck('currency_code'); + if ($state !== null) { + $pricing = $pricing->filter(function ($code) use ($state) { + return $code !== $state; + }); + } + $pricing = $pricing->filter(function ($code) { + return $code !== null; + }); + + return Currency::whereNotIn('code', $pricing)->pluck('code', 'code'); + }) + ->live() + ->default(config('settings.default_currency')) + ->required(), + Forms\Components\TextInput::make('price') + ->required() + ->label('Price') + // Suffix based on chosen currency + ->prefix(fn (Get $get) => Currency::where('code', $get('currency_code'))->first()?->prefix) + ->suffix(fn (Get $get) => Currency::where('code', $get('currency_code'))->first()?->suffix) + ->live(onBlur: true) + ->mask(RawJs::make( + <<<'JS' + $money($input, '.', '', 2) + JS + )) + ->numeric() + ->minValue(0) + ->hidden(fn (Get $get) => $get('type') === 'free'), + Forms\Components\TextInput::make('setup_fee') + ->label('Setup fee') + ->live(onBlur: true) + ->mask(RawJs::make( + <<<'JS' + $money($input, '.', '', 2) + JS + )) + ->numeric() + ->minValue(0) + ->hidden(fn (Get $get) => $get('type') === 'free'), + ]), + ]); + } + + public static function table(Table $table): Table + { + return $table + ->columns([ + Tables\Columns\TextColumn::make('name')->searchable(), + Tables\Columns\TextColumn::make('slug'), + Tables\Columns\TextColumn::make('category.name')->searchable(), + ]) + ->filters([ + Tables\Filters\SelectFilter::make('category') + ->relationship('category', 'name') + ->searchable() + ->preload(), + ]) + ->actions([ + Tables\Actions\EditAction::make(), + ]) + ->defaultSort(function (Builder $query): Builder { + return $query + ->orderBy('sort', 'asc'); + }) + ->reorderable('sort') + ->defaultGroup('category.name'); + } + + public static function getPages(): array + { + return [ + 'index' => Pages\ListProducts::route('/'), + 'create' => Pages\CreateProduct::route('/create'), + 'edit' => Pages\EditProduct::route('/{record}/edit'), + ]; + } +} diff --git a/app/Admin/Resources/ProductResource/Pages/CreateProduct.php b/app/Admin/Resources/ProductResource/Pages/CreateProduct.php new file mode 100644 index 000000000..7b7e3ffbc --- /dev/null +++ b/app/Admin/Resources/ProductResource/Pages/CreateProduct.php @@ -0,0 +1,46 @@ + $option['name'], + 'settingable_id' => $record->id, + 'settingable_type' => $record->getMorphClass(), + 'type' => $option['database_type'] ?? 'string', + 'value' => isset($data['settings'][$option['name']]) ? (is_array($data['settings'][$option['name']]) ? json_encode($data['settings'][$option['name']]) : $data['settings'][$option['name']]) : null, + ]; + }, $product_config); + + $record->settings()->upsert($things, uniqueBy: [ + 'key', + 'settingable_id', + 'settingable_type', + ], update: [ + 'type', + 'value', + ]); + + return $record; + } +} diff --git a/app/Admin/Resources/ProductResource/Pages/EditProduct.php b/app/Admin/Resources/ProductResource/Pages/EditProduct.php new file mode 100644 index 000000000..2aa964381 --- /dev/null +++ b/app/Admin/Resources/ProductResource/Pages/EditProduct.php @@ -0,0 +1,123 @@ +label('Duplicate') + ->requiresConfirmation() + ->action(function (Product $record) { + $new_record = $record->replicate(); + $new_record->name = 'Copy of ' . $record->name; + $new_record->slug = Str::slug($new_record->name); + $new_record->save(); + + // Duplicate settings + $record->settings->each(function ($setting) use ($new_record) { + $new_setting = $setting->replicate(); + $new_setting->settingable_id = $new_record->id; + $new_setting->save(); + }); + + $record->upgrades->each(function ($upgrade) use ($new_record) { + // Duplicate the upgrade relationship (its a belongsToMany, so we need to use the pivot table) + $new_record->upgrades()->attach($upgrade->id); + }); + + // Duplicate plans and their prices + $record->plans->each(function ($plan) use ($new_record) { + $new_plan = $plan->replicate(); + $new_plan->priceable_id = $new_record->id; + $new_plan->save(); + + // Duplicate plan prices + $plan->prices->each(function ($price) use ($new_plan) { + $new_price = $price->replicate(); + $new_price->plan_id = $new_plan->id; + $new_price->save(); + }); + }); + + Notification::make() + ->title('Product duplicated successfully!') + ->success() + ->send(); + + return $this->redirect(static::getResource()::getUrl('edit', [ + 'record' => $new_record, + ]), true); + }), + Actions\DeleteAction::make() + ->before(function (Product $record, Actions\DeleteAction $action) { + if ($record->services()->count() > 0) { + Notification::make() + ->title('Whoops!') + ->body('You cannot delete this plan because it is being used by one or more services.') + ->danger() + ->send(); + $action->cancel(); + } + })->after(function (Product $record) { + $record->settings()->delete(); + }), + ]; + } + + protected function mutateFormDataBeforeFill(array $data): array + { + foreach ($this->record->settings as $setting) { + $data['settings'][$setting->key] = $setting->value; + } + + return $data; + } + + protected function handleRecordUpdate(Model $record, array $data): Model + { + $record->update(Arr::except($data, ['settings'])); + + if (!isset($data['settings'])) { + return $record; + } + + $product_config = ExtensionHelper::getProductConfig(Server::findOrFail($data['server_id']), $data['settings']); + + $things = array_map(function ($option) use ($data, $record) { + return [ + 'key' => $option['name'], + 'settingable_id' => $record->id, + 'settingable_type' => $record->getMorphClass(), + 'type' => $option['database_type'] ?? 'string', + 'value' => isset($data['settings'][$option['name']]) ? (is_array($data['settings'][$option['name']]) ? json_encode($data['settings'][$option['name']]) : $data['settings'][$option['name']]) : null, + ]; + }, $product_config); + + $record->settings()->upsert($things, uniqueBy: [ + 'key', + 'settingable_id', + 'settingable_type', + ], update: [ + 'type', + 'value', + ]); + + return $record; + } +} diff --git a/app/Admin/Resources/ProductResource/Pages/ListProducts.php b/app/Admin/Resources/ProductResource/Pages/ListProducts.php new file mode 100644 index 000000000..289e623ed --- /dev/null +++ b/app/Admin/Resources/ProductResource/Pages/ListProducts.php @@ -0,0 +1,19 @@ +schema([ + TextInput::make('name') + ->unique(ignoreRecord: true) + ->required() + ->maxLength(255), + // Split permission UI into core and extension + Fieldset::make('Core Permissions') + ->schema([ + CheckboxList::make('permissions') + ->label(false) + ->options(static::getCorePermissions()) + ->columns(4) + ->bulkToggleable() + ->searchable() + ->noSearchResultsMessage('Permission could not be found')->columnSpanFull(), + ]), + + Fieldset::make('Extension Permissions') + ->schema([ + CheckboxList::make('permissions') + ->label(false) + ->options(static::getExtensionPermissions()) + ->columns(4) + ->bulkToggleable() + ->searchable() + ->noSearchResultsMessage('Permission could not be found')->columnSpanFull(), + ]), + ])->columns(1); + } + + public static function table(Table $table): Table + { + return $table + ->columns([ + TextColumn::make('name')->sortable(), + TextColumn::make('permissions')->formatStateUsing(fn (Role $record): string => in_array('*', $record->permissions) ? 'All' : count($record->permissions))->sortable(), + ]) + ->filters([ + // + ]) + ->actions([ + Tables\Actions\EditAction::make(), + ]); + } + + public static function getRelations(): array + { + return [ + // + ]; + } + + public static function canEdit(Model $record): bool + { + return $record->id !== 1; + } + + public static function getPages(): array + { + return [ + 'index' => Pages\ListRoles::route('/'), + 'create' => Pages\CreateRole::route('/create'), + 'edit' => Pages\EditRole::route('/{record}/edit'), + ]; + } + + protected static function getCorePermissions(): array + { + return Arr::dot(config('permissions.role')); + } + + protected static function getExtensionPermissions(): array + { + return collect(Event::dispatch('permissions')) + ->filter(fn ($response) => is_array($response)) + ->pipe(fn ($merged) => Arr::dot($merged)); + } +} diff --git a/app/Admin/Resources/RoleResource/Pages/CreateRole.php b/app/Admin/Resources/RoleResource/Pages/CreateRole.php new file mode 100644 index 000000000..fa41f6364 --- /dev/null +++ b/app/Admin/Resources/RoleResource/Pages/CreateRole.php @@ -0,0 +1,11 @@ +name; + } + + public static function form(Form $form): Form + { + $servers = \App\Helpers\ExtensionHelper::getExtensions('server'); + + return $form + ->schema([ + Forms\Components\TextInput::make('name') + ->label('Name') + ->required() + ->maxLength(255) + ->unique(static::getModel(), 'name', ignoreRecord: true) + ->placeholder('Enter the name of the server'), + Forms\Components\Select::make('extension') + ->label('Server') + ->required() + ->searchable() + ->options(array_combine( + array_column($servers, 'name'), + array_column($servers, 'name') + )) + ->live(onBlur: true) + ->disabledOn('edit') + ->afterStateUpdated(fn (Select $component) => $component + ->getContainer() + ->getComponent('settings') + ->getChildComponentContainer() + ->fill()) + ->placeholder('Select the type of the server') + ->hintAction( + Action::make('Test Configuration') + ->action(function (Get $get, $record) { + // Dd settings + $connection = ExtensionHelper::testConfig($record, $get('settings')); + + if ($connection === true) { + Notification::make() + ->title('Configuration is correct') + ->success()->send(); + } else { + Notification::make() + ->title('Connection failed: ' . $connection) + ->danger()->send(); + } + }) + ->label('Test Connection') + ->hidden(function ($record) { + // If record is empty or textConfig is not available, then hide the button + return empty($record) || !ExtensionHelper::hasFunction($record, 'testConfig'); + }) + ), + Section::make('Server Settings') + ->description('Specific settings for the selected server') + ->schema([ + Grid::make()->schema(fn (Get $get) => ExtensionHelper::getConfigAsInputs('server', $get('extension'), $get('settings')))->key('settings'), + ]), + ]); + } + + public static function table(Table $table): Table + { + return $table + ->columns([ + Tables\Columns\TextColumn::make('name')->searchable(), + ]) + ->filters([ + // + ]) + ->actions([ + Tables\Actions\EditAction::make(), + ]) + ->bulkActions([ + Tables\Actions\BulkActionGroup::make([ + Tables\Actions\DeleteBulkAction::make(), + ]), + ]); + } + + public static function getRelations(): array + { + return [ + // + ]; + } + + public static function getPages(): array + { + return [ + 'index' => Pages\ListServers::route('/'), + 'create' => Pages\CreateServer::route('/create'), + 'edit' => Pages\EditServer::route('/{record}/edit'), + ]; + } +} diff --git a/app/Admin/Resources/ServerResource/Pages/CreateServer.php b/app/Admin/Resources/ServerResource/Pages/CreateServer.php new file mode 100644 index 000000000..a6639e640 --- /dev/null +++ b/app/Admin/Resources/ServerResource/Pages/CreateServer.php @@ -0,0 +1,44 @@ + $value) { + if (is_null($value)) { + continue; + } + $record->settings()->updateOrCreate([ + 'key' => $key, + ], [ + 'value' => $value, + ]); + } + + ExtensionHelper::call($record, 'enabled', [$record], mayFail: true); + + return $record; + } +} diff --git a/app/Admin/Resources/ServerResource/Pages/EditServer.php b/app/Admin/Resources/ServerResource/Pages/EditServer.php new file mode 100644 index 000000000..7ce2242e0 --- /dev/null +++ b/app/Admin/Resources/ServerResource/Pages/EditServer.php @@ -0,0 +1,59 @@ +before(fn ($record) => ExtensionHelper::call($record, 'disabled', [$record], mayFail: true)), + ]; + } + + protected function mutateFormDataBeforeFill(array $data): array + { + foreach ($this->record->settings as $setting) { + $data['settings'][$setting->key] = $setting->value; + } + + return $data; + } + + protected function handleRecordUpdate(Model $record, array $data): Model + { + $record->update(Arr::except($data, ['settings'])); + + if (!isset($data['settings'])) { + return $record; + } + + $config = ExtensionHelper::getConfig($record->type, $record->extension); + + foreach ($config as $option) { + $record->settings()->updateOrCreate([ + 'key' => $option['name'], + 'settingable_id' => $record->id, + 'settingable_type' => $record->getMorphClass(), + ], [ + 'type' => $option['database_type'] ?? 'string', + 'value' => isset($data['settings'][$option['name']]) ? (is_array($data['settings'][$option['name']]) ? json_encode($data['settings'][$option['name']]) : $data['settings'][$option['name']]) : null, + 'encrypted' => $option['encrypted'] ?? false, + ]); + } + + ExtensionHelper::call($record, 'updated', [$record], mayFail: true); + + // Maybe the extension changed the record, so we need to refresh it + return $record->refresh(); + } +} diff --git a/app/Admin/Resources/ServerResource/Pages/ListServers.php b/app/Admin/Resources/ServerResource/Pages/ListServers.php new file mode 100644 index 000000000..576707ce7 --- /dev/null +++ b/app/Admin/Resources/ServerResource/Pages/ListServers.php @@ -0,0 +1,19 @@ +schema([ + Forms\Components\Select::make('service_id') + ->relationship('service', 'id', fn (Builder $query) => $query->where('status', 'active')) + ->getOptionLabelFromRecordUsing(fn ($record) => $record->product->name . ' - ' . $record->plan->name . ' #' . $record->id . ($record->order && $record->order->user ? ' (' . $record->order->user->email . ')' : '')) + ->searchable() + ->preload() + ->disabledOn('edit') + ->required(), + Forms\Components\TextInput::make('reason') + ->maxLength(255) + ->default(null), + Forms\Components\Select::make('type') + ->options([ + 'end_of_period' => 'End of Period', + 'immediate' => 'Immediate', + ]) + ->disabledOn('edit') + ->required(), + ]); + } + + public static function table(Table $table): Table + { + return $table + ->columns([ + Tables\Columns\TextColumn::make('service_id') + ->numeric() + ->sortable(), + Tables\Columns\TextColumn::make('reason') + ->searchable(), + Tables\Columns\TextColumn::make('type'), + Tables\Columns\TextColumn::make('deleted_at') + ->dateTime() + ->sortable() + ->toggleable(isToggledHiddenByDefault: true), + Tables\Columns\TextColumn::make('created_at') + ->dateTime() + ->sortable() + ->toggleable(isToggledHiddenByDefault: true), + Tables\Columns\TextColumn::make('updated_at') + ->dateTime() + ->sortable() + ->toggleable(isToggledHiddenByDefault: true), + ]) + ->filters([ + // + ]) + ->actions([ + Tables\Actions\EditAction::make(), + ]) + ->bulkActions([ + Tables\Actions\BulkActionGroup::make([ + Tables\Actions\DeleteBulkAction::make(), + ]), + ]); + } + + public static function getRelations(): array + { + return [ + // + ]; + } + + public static function getPages(): array + { + return [ + 'index' => Pages\ListServiceCancellations::route('/'), + 'create' => Pages\CreateServiceCancellation::route('/create'), + 'edit' => Pages\EditServiceCancellation::route('/{record}/edit'), + ]; + } +} diff --git a/app/Admin/Resources/ServiceCancellationResource/Pages/CreateServiceCancellation.php b/app/Admin/Resources/ServiceCancellationResource/Pages/CreateServiceCancellation.php new file mode 100644 index 000000000..13ed08713 --- /dev/null +++ b/app/Admin/Resources/ServiceCancellationResource/Pages/CreateServiceCancellation.php @@ -0,0 +1,11 @@ +count() ?: null; + } + + public static function getNavigationBadgeColor(): ?string + { + return 'warning'; + } + + protected static ?string $cluster = Services::class; + + public static function form(Form $form): Form + { + return $form + ->schema([ + Forms\Components\Select::make('product_id') + ->label('Product') + ->required() + ->options(Product::all()->pluck('name', 'id')->toArray()) + ->searchable() + ->live() + ->preload() + ->placeholder('Select the product'), + Forms\Components\Select::make('plan_id') + ->label('Plan') + ->required() + ->relationship('plan', 'name', fn (Builder $query, Get $get) => $query->where('priceable_id', $get('product_id'))->where('priceable_type', Product::class)) + ->searchable() + ->preload() + ->disabled(fn (Get $get) => !$get('product_id')) + ->placeholder('Select the plan'), + UserComponent::make('user_id'), + Forms\Components\Select::make('status') + ->label('Status') + ->required() + ->options([ + // active, pending, suspended, cancelled + 'active' => 'Active', + 'pending' => 'Pending', + 'suspended' => 'Suspended', + 'cancelled' => 'Cancelled', + ]) + ->default('pending'), + Forms\Components\TextInput::make('quantity') + ->label('Quantity') + ->required() + ->placeholder('Enter the quantity'), + Forms\Components\DatePicker::make('expires_at') + ->label('Expires At') + ->required() + ->placeholder('Select the expiration date'), + Forms\Components\Select::make('coupon_id') + ->label('Coupon') + ->relationship('coupon', 'code') + ->searchable() + ->placeholder('Select the coupon'), + Forms\Components\Select::make('currency_code') + ->options(function (Get $get, ?string $state) { + $pricing = collect($get('../../pricing'))->pluck('currency_code'); + if ($state !== null) { + $pricing = $pricing->filter(function ($code) use ($state) { + return $code !== $state; + }); + } + $pricing = $pricing->filter(function ($code) { + return $code !== null; + }); + + return Currency::whereNotIn('code', $pricing)->pluck('code', 'code'); + }) + ->live() + ->default(config('settings.default_currency')) + ->required(), + Forms\Components\TextInput::make('price') + ->required() + ->label('Price') + // Suffix based on chosen currency + ->prefix(fn (Get $get) => Currency::where('code', $get('currency_code'))->first()?->prefix) + ->suffix(fn (Get $get) => Currency::where('code', $get('currency_code'))->first()?->suffix) + ->live(onBlur: true) + ->mask(RawJs::make( + <<<'JS' + $money($input, '.', '', 2) + JS + )) + ->numeric() + ->minValue(0), + Forms\Components\TextInput::make('subscription_id') + ->label('Subscription ID') + ->nullable() + ->placeholder('Enter the subscription ID') + ->hintAction( + Action::make('Cancel Subscription ID') + ->action(function (Component $component) { + if (ExtensionHelper::cancelSubscription($component->getRecord())) { + Notification::make('Subscription Cancelled') + ->title('The subscription has been successfully cancelled') + ->success() + ->send(); + } else { + Notification::make('Subscription Not Cancelled') + ->title('The subscription could not be cancelled') + ->error() + ->send(); + } + // Update the record to remove the subscription ID + $component->getRecord()->update(['subscription_id' => null]); + }) + ->requiresConfirmation() + ->label('Cancel Subscription') + ->hidden(fn (Component $component) => !$component->getRecord()?->subscription_id), + ), + ]); + } + + public static function table(Table $table): Table + { + return $table + ->columns([ + Tables\Columns\TextColumn::make('id') + ->label('ID') + ->sortable() + ->searchable(), + Tables\Columns\TextColumn::make('user.name') + ->label('User') + ->searchable(true, fn (Builder $query, string $search) => $query->whereHas('user', fn (Builder $query) => $query->where('first_name', 'like', "%$search%")->orWhere('last_name', 'like', "%$search%"))), + Tables\Columns\TextColumn::make('product.name') + ->label('Product') + ->searchable() + ->sortable(), + Tables\Columns\TextColumn::make('status') + ->badge() + ->color(fn (Service $record) => match ($record->status) { + 'pending' => 'gray', + 'active' => 'success', + 'cancelled' => 'danger', + 'suspended' => 'warning', + }) + ->formatStateUsing(fn (string $state) => ucfirst($state)) + ->label('Status') + ->searchable() + ->sortable(), + Tables\Columns\TextColumn::make('expires_at') + ->label('Expires At') + ->date() + ->searchable() + ->sortable(), + ]) + ->filters([ + Tables\Filters\SelectFilter::make('status') + ->label('Status') + ->options([ + 'active' => 'Active', + 'pending' => 'Pending', + 'suspended' => 'Suspended', + 'cancelled' => 'Cancelled', + ]), + ]) + ->defaultSort(function (Builder $query): Builder { + return $query + ->orderBy('id', 'desc'); + }) + ->actions([ + Tables\Actions\EditAction::make(), + ]) + ->bulkActions([ + Tables\Actions\BulkActionGroup::make([ + Tables\Actions\DeleteBulkAction::make(), + ]), + ]); + } + + public static function getRelations(): array + { + return [ + RelationManagers\InvoicesRelationManager::class, + PropertiesRelationManager::class, + RelationManagers\ConfigOptionsRelationManager::class, + ]; + } + + public static function getPages(): array + { + return [ + 'index' => Pages\ListService::route('/'), + 'create' => Pages\CreateService::route('/create'), + 'edit' => Pages\EditService::route('/{record}/edit'), + ]; + } +} diff --git a/app/Admin/Resources/ServiceResource/Pages/CreateService.php b/app/Admin/Resources/ServiceResource/Pages/CreateService.php new file mode 100644 index 000000000..732bed41a --- /dev/null +++ b/app/Admin/Resources/ServiceResource/Pages/CreateService.php @@ -0,0 +1,11 @@ +label('Trigger Extension Action') + ->form([ + Select::make('action') + ->label('Action') + ->options([ + 'create' => 'Create server', + 'suspend' => 'Suspend server', + 'unsuspend' => 'Unsuspend server', + 'terminate' => 'Terminate server', + 'upgrade' => 'Upgrade server', + ])->required(), + Checkbox::make('sendNotification') + ->label('Send Notification') + ->default(false), + ]) + ->action(function (array $data, Service $record, Actions\Action $action): void { + try { + switch ($data['action']) { + case 'create': + $sdata = ExtensionHelper::createServer($record); + if ($data['sendNotification']) { + NotificationHelper::serverCreatedNotification($record->order->user, $record, $sdata); + } + break; + case 'suspend': + $sdata = ExtensionHelper::suspendServer($record); + break; + case 'unsuspend': + $sdata = ExtensionHelper::unsuspendServer($record); + break; + case 'terminate': + $sdata = ExtensionHelper::terminateServer($record); + break; + case 'upgrade': + $sdata = ExtensionHelper::upgradeServer($record); + break; + } + } catch (\Exception $e) { + if (config('app.debug')) { + throw $e; + } + Log::error($e); + Notification::make('Error') + ->title('Error occured while triggering the action:') + ->body($e->getMessage()) + ->danger() + ->send(); + $action->halt(); + } + Notification::make('Success') + ->title('Action triggered successfully') + ->body('The action has been triggered successfully') + ->success() + ->send(); + }) + ->color('primary') + ->modalSubmitActionLabel('Trigger'), + + ]; + } +} diff --git a/app/Admin/Resources/ServiceResource/Pages/ListService.php b/app/Admin/Resources/ServiceResource/Pages/ListService.php new file mode 100644 index 000000000..78a0764d2 --- /dev/null +++ b/app/Admin/Resources/ServiceResource/Pages/ListService.php @@ -0,0 +1,19 @@ +schema([ + Forms\Components\Select::make('configValue.id') + ->label('Config Value') + ->required() + ->relationship('configValue', 'id', fn (Builder $query, ServiceConfig $record) => $query->where('parent_id', $record->config_option_id)) + ->searchable() + ->getOptionLabelFromRecordUsing(fn ($record) => $record->name) + ->getSearchResultsUsing(fn (string $search, ServiceConfig $record): array => $record->configOption->children()->where('name', 'like', "%$search%")->limit(50)->pluck('name', 'id')->toArray()) + ->live() + ->preload() + ->required(), + ]); + } + + public function table(Table $table): Table + { + return $table + ->recordTitleAttribute('configOption.name') + ->columns([ + Tables\Columns\TextColumn::make('configOption.name'), + Tables\Columns\TextColumn::make('configValue.name'), + ]) + ->filters([ + // + ]) + ->headerActions([]) + ->actions([ + Tables\Actions\EditAction::make(), + Tables\Actions\DeleteAction::make(), + ]); + } +} diff --git a/app/Admin/Resources/ServiceResource/RelationManagers/InvoicesRelationManager.php b/app/Admin/Resources/ServiceResource/RelationManagers/InvoicesRelationManager.php new file mode 100644 index 000000000..8cae9802e --- /dev/null +++ b/app/Admin/Resources/ServiceResource/RelationManagers/InvoicesRelationManager.php @@ -0,0 +1,71 @@ +whereIn('id', InvoiceItem::query() + ->where(function ($query) { + $query->where('reference_type', 'App\Models\Service') + ->where('reference_id', $this->ownerRecord->id); + })->orWhere(function ($query) { + $query->where('reference_type', 'App\Models\ServiceUpgrade') + ->whereIn('reference_id', $this->ownerRecord->upgrade()->pluck('id')->filter()); + })->pluck('invoice_id')); + } + + public function form(Form $form): Form + { + return $form + ->schema([ + Forms\Components\TextInput::make('id') + ->required() + ->maxLength(255), + + ]); + } + + public function table(Table $table): Table + { + return $table + ->recordTitleAttribute('id') + ->columns([ + Tables\Columns\TextColumn::make('id')->sortable(), + Tables\Columns\TextColumn::make('formattedTotal')->label('Total'), + Tables\Columns\TextColumn::make('status') + ->label('Status') + // Make first letter uppercase + ->formatStateUsing(fn (string $state): string => ucfirst($state)) + ->badge() + ->color(fn (string $state): string => match ($state) { + 'paid' => 'success', + 'pending' => 'warning', + default => 'danger', + }), + ]) + ->filters([ + // + ]) + ->headerActions([]) + ->actions([ + Tables\Actions\ViewAction::make()->url(fn ($record) => InvoiceResource::getUrl('edit', ['record' => $record])), + ]) + ->defaultSort('invoices.id', 'desc'); + } +} diff --git a/app/Admin/Resources/TaxRateResource.php b/app/Admin/Resources/TaxRateResource.php new file mode 100644 index 000000000..c1dd280f3 --- /dev/null +++ b/app/Admin/Resources/TaxRateResource.php @@ -0,0 +1,103 @@ + 'All Countries'] + config('app.countries'); + unset($countries['']); + + return $form + ->schema([ + Forms\Components\TextInput::make('name') + ->label('Name') + ->required() + ->maxLength(255) + ->placeholder('Enter the name of the tax rate'), + Forms\Components\TextInput::make('rate') + ->label('Rate') + ->mask(RawJs::make( + <<<'JS' + $money($input, '.', '', 2) + JS + )) + ->required() + ->suffix('%') + ->placeholder('Enter the rate of the tax rate'), + Forms\Components\Select::make('country') + ->label('Country') + ->required() + ->unique(null, 'country', ignoreRecord: true) + ->options($countries), + ]); + } + + public static function table(Table $table): Table + { + return $table + ->columns([ + Tables\Columns\TextColumn::make('name') + ->label('Name') + ->searchable() + ->sortable(), + Tables\Columns\TextColumn::make('rate') + ->label('Rate') + ->searchable() + ->sortable(), + Tables\Columns\TextColumn::make('country') + ->formatStateUsing(fn (string $state): string => config('app.countries')[$state] ?? 'All Countries') + ->label('Country') + ->searchable() + ->sortable(), + ]) + ->filters([ + // + ]) + ->actions([ + Tables\Actions\EditAction::make(), + ]) + ->bulkActions([ + Tables\Actions\BulkActionGroup::make([ + Tables\Actions\DeleteBulkAction::make(), + ]), + ]); + } + + public static function getRelations(): array + { + return [ + // + ]; + } + + public static function canAccess(): bool + { + return config('settings.tax_enabled') ? true && static::canViewAny() : false; + } + + public static function getPages(): array + { + return [ + 'index' => Pages\ListTaxRates::route('/'), + 'create' => Pages\CreateTaxRate::route('/create'), + 'edit' => Pages\EditTaxRate::route('/{record}/edit'), + ]; + } +} diff --git a/app/Admin/Resources/TaxRateResource/Pages/CreateTaxRate.php b/app/Admin/Resources/TaxRateResource/Pages/CreateTaxRate.php new file mode 100644 index 000000000..98b9ea21e --- /dev/null +++ b/app/Admin/Resources/TaxRateResource/Pages/CreateTaxRate.php @@ -0,0 +1,11 @@ +count() ?: null; + } + + public static function getNavigationBadgeColor(): ?string + { + return 'danger'; + } + + public static function getGloballySearchableAttributes(): array + { + return ['subject']; + } + + public static function getGlobalSearchResultTitle(Model $record): string|Htmlable + { + return $record->subject; + } + + public static function form(Form $form): Form + { + return $form + ->schema([ + Forms\Components\TextInput::make('subject') + ->label('Subject') + ->columnSpan(function ($record) { + return $record ? 2 : 1; + }) + ->required(), + Forms\Components\Select::make('status') + ->label('Status') + ->options([ + 'open' => 'Open', + 'closed' => 'Closed', + 'replied' => 'Replied', + ]) + ->columnSpan(function ($record) { + return $record ? 2 : 1; + }) + ->default('open') + ->required(), + Forms\Components\Select::make('priority') + ->label('Priority') + ->options([ + 'low' => 'Low', + 'medium' => 'Medium', + 'high' => 'High', + ]) + ->columnSpan(function ($record) { + return $record ? 2 : 1; + }) + ->default('medium') + ->required(), + Forms\Components\Select::make('department') + ->label('Department') + ->options(array_combine(config('settings.ticket_departments'), config('settings.ticket_departments'))) + ->columnSpan(function ($record) { + return $record ? 2 : 1; + }), + UserComponent::make('user_id')->columnSpan(function ($record) { + return $record ? 2 : 1; + }), + Forms\Components\Select::make('assigned_to') + ->label('Assigned To') + ->searchable() + ->preload() + ->relationship('user', 'id', fn (Builder $query) => $query->where('role_id', '!=', null)) + ->getOptionLabelFromRecordUsing(fn ($record) => $record->name) + ->columnSpan(function ($record) { + return $record ? 2 : 1; + }), + Forms\Components\Select::make('service_id') + ->label('Service') + ->relationship('service', 'id', function (Builder $query, Get $get) { + $query->where('user_id', $get('user_id')); + }) + ->getOptionLabelFromRecordUsing(fn ($record) => "{$record->product->name} - " . ucfirst($record->status)) + ->columnSpan(function ($record) { + return $record ? 2 : 1; + }) + ->disabled(fn (Get $get) => !$get('user_id')), + Forms\Components\MarkdownEditor::make('message') + ->columnSpan(2) + ->label('Initial Message') + ->hiddenOn('edit') + ->columnSpan(function ($record) { + return $record ? 2 : 1; + }) + ->required(), + ]); + } + + public static function table(Table $table): Table + { + return $table + ->columns([ + Tables\Columns\TextColumn::make('subject') + ->limit(40) + ->searchable() + ->sortable(), + Tables\Columns\TextColumn::make('status') + ->sortable() + ->badge() + ->color(fn (Ticket $record) => match ($record->status) { + 'open' => 'success', + 'closed' => 'danger', + 'replied' => 'warning', + }) + ->formatStateUsing(fn (string $state) => ucfirst($state)), + Tables\Columns\TextColumn::make('priority') + ->sortable() + ->badge() + ->color(fn (Ticket $record) => match ($record->priority) { + 'low' => 'success', + 'medium' => 'gray', + 'high' => 'danger', + }) + ->formatStateUsing(fn (string $state) => ucfirst($state)), + Tables\Columns\TextColumn::make('department') + ->formatStateUsing(fn ($state) => array_combine(config('settings.ticket_departments'), config('settings.ticket_departments'))[$state]) + ->sortable(), + Tables\Columns\TextColumn::make('user.name') + ->searchable(['first_name', 'last_name']) + ->sortable(['first_name', 'last_name']), + Tables\Columns\TextColumn::make('user.email') + ->label('Email') + ->searchable(), + ]) + ->filters([ + // + ]) + ->actions([ + Tables\Actions\EditAction::make(), + ]) + ->bulkActions([ + Tables\Actions\BulkActionGroup::make([ + Tables\Actions\DeleteBulkAction::make(), + ]), + ]) + ->defaultSort('id', 'desc'); + } + + public static function getWidgets(): array + { + return [ + TicketsOverView::class, + ]; + } + + public static function getPages(): array + { + return [ + 'index' => Pages\ListTickets::route('/'), + 'create' => Pages\CreateTicket::route('/create'), + 'edit' => Pages\EditTicket::route('/{record}/edit'), + ]; + } +} diff --git a/app/Admin/Resources/TicketResource/Pages/CreateTicket.php b/app/Admin/Resources/TicketResource/Pages/CreateTicket.php new file mode 100644 index 000000000..a9e8cbf00 --- /dev/null +++ b/app/Admin/Resources/TicketResource/Pages/CreateTicket.php @@ -0,0 +1,25 @@ +messages()->create([ + 'user_id' => Auth::id(), + 'message' => $data['message'], + ]); + + return $record; + } +} diff --git a/app/Admin/Resources/TicketResource/Pages/EditTicket.php b/app/Admin/Resources/TicketResource/Pages/EditTicket.php new file mode 100644 index 000000000..c9e3fbbc7 --- /dev/null +++ b/app/Admin/Resources/TicketResource/Pages/EditTicket.php @@ -0,0 +1,211 @@ +schema([ + Forms\Components\MarkdownEditor::make('message') + ->label('Message') + ->columnSpanFull() + ->required(), + ]); + } + + protected function getFormActions(): array + { + return [ + $this->getSaveFormAction()->label('Send Message'), + $this->getCancelFormAction(), + ]; + } + + // Save action + protected function handleRecordUpdate(Model $record, array $data): Model + { + $record->messages()->create([ + 'user_id' => Auth::id(), + 'message' => $data['message'], + ]); + + return $record; + } +public function mount(string|int $record): void +{ + parent::mount($record); + + $this->form->fill([ + 'message' => <<form->fill(); + } + + public function infolist(Infolist $infolist): Infolist + { + return $infolist + ->record($this->record) + ->columns(['default' => 3, 'md' => 1]) + ->schema([ + Infolists\Components\TextEntry::make('user_id') + ->size(TextEntrySize::Large) + ->formatStateUsing(fn ($record) => $record->user->name) + ->url(fn ($record) => UserResource::getUrl('edit', ['record' => $record->user])) + ->label('User ID'), + Infolists\Components\TextEntry::make('subject') + ->size(TextEntrySize::Large) + ->label('Subject'), + Infolists\Components\TextEntry::make('status') + ->size(TextEntrySize::Large) + ->badge() + ->formatStateUsing(fn ($state) => ucfirst($state)) + ->color(fn ($state) => match ($state) { + 'open' => 'success', + 'closed' => 'danger', + 'replied' => 'gray', + }) + ->label('Status'), + Infolists\Components\TextEntry::make('priority') + ->size(TextEntrySize::Large) + ->badge() + ->formatStateUsing(fn ($state) => ucfirst($state)) + ->color(fn ($state) => match ($state) { + 'low' => 'success', + 'medium' => 'gray', + 'high' => 'danger', + }) + ->label('Priority'), + Infolists\Components\TextEntry::make('department') + ->size(TextEntrySize::Large) + ->formatStateUsing(fn ($state) => array_combine(config('settings.ticket_departments'), config('settings.ticket_departments'))[$state]) + ->placeholder('No department') + ->label('Department'), + + Infolists\Components\TextEntry::make('assigned_to') + ->size(TextEntrySize::Large) + ->label('Assigned To') + ->placeholder('No assigned user') + ->formatStateUsing(fn ($record) => $record->assignedTo->name), + + Infolists\Components\TextEntry::make('service_id') + ->size(TextEntrySize::Large) + ->label('Service') + ->url(fn ($record) => $record->service ? ServiceResource::getUrl('edit', ['record' => $record->service]) : null) + ->placeholder('No service') + ->formatStateUsing(fn ($record) => "{$record->service->product->name} - " . ucfirst($record->service->status)), + + InfolistActions::make([ + Action::make('Edit') + ->form(function (Form $form) { + return $form + ->columns(2) + ->schema([ + Forms\Components\Select::make('status') + ->label('Status') + ->options([ + 'open' => 'Open', + 'closed' => 'Closed', + 'replied' => 'Replied', + ]) + ->default('open') + ->required(), + Forms\Components\Select::make('priority') + ->label('Priority') + ->options([ + 'low' => 'Low', + 'medium' => 'Medium', + 'high' => 'High', + ]) + ->default('medium') + ->required(), + Forms\Components\Select::make('department') + ->label('Department') + ->options(array_combine(config('settings.ticket_departments'), config('settings.ticket_departments'))), + UserComponent::make('user_id'), + Forms\Components\Select::make('assigned_to') + ->label('Assigned To') + ->relationship('assignedTo', 'id', fn (Builder $query) => $query->where('role_id', '!=', null)) + ->searchable() + ->preload() + ->getOptionLabelFromRecordUsing(fn ($record) => $record->name), + Forms\Components\Select::make('service_id') + ->label('Service') + ->relationship('service', 'id', function (Builder $query, Get $get) { + $query->where('user_id', $get('user_id')); + }) + ->getOptionLabelFromRecordUsing(fn ($record) => "{$record->product->name} - " . ucfirst($record->status)) + ->disabled(fn (Get $get) => !$get('user_id')), + ]); + }) + ->fillForm(fn ($record) => [ + 'status' => $record->status, + 'priority' => $record->priority, + 'department' => $record->department, + 'user_id' => $record->user_id, + 'assigned_to' => $record->assigned_to, + 'service_id' => $record->service_id, + ]) + ->action(function (array $data, Ticket $record): void { + $record->update($data); + }) + ->hidden(!auth()->user()->can('update', $this->record)) + ->icon('heroicon-o-pencil'), + Action::make('Delete') + ->color('danger') + ->icon('heroicon-o-trash') + ->requiresConfirmation() + ->action(fn (Ticket $record) => $record->delete()) + ->hidden(!auth()->user()->can('delete', $this->record)) + ->after(function (StaticAction $action) { + Notification::make() + ->title(__('filament-actions::delete.single.notifications.deleted.title')) + ->success() + ->send(); + + $action->redirect(TicketResource::getUrl('index')); + }), + ])->columnSpan(['default' => 'full', 'md' => 1]), + ]); + } + + public function deleteMessage(TicketMessage $message): void + { + if (auth()->user()->can('delete', $message)) { + $message->delete(); + } + } +} diff --git a/app/Admin/Resources/TicketResource/Pages/ListTickets.php b/app/Admin/Resources/TicketResource/Pages/ListTickets.php new file mode 100644 index 000000000..879fe3272 --- /dev/null +++ b/app/Admin/Resources/TicketResource/Pages/ListTickets.php @@ -0,0 +1,51 @@ + Tab::make('All Tickets') + ->icon('heroicon-m-user-group'), + 'open' => Tab::make('Open Tickets') + ->icon('heroicon-o-lock-open') + ->modifyQueryUsing(fn (Builder $query) => $query->where('status', 'open')), + 'replied' => Tab::make('Replied Tickets') + ->icon('heroicon-o-chat-bubble-left-right') + ->modifyQueryUsing(fn (Builder $query) => $query->where('status', 'replied')), + 'closed' => Tab::make('Closed Tickets') + ->icon('heroicon-o-lock-closed') + ->modifyQueryUsing(fn (Builder $query) => $query->where('status', 'closed')), + ]; + } + + public function getDefaultActiveTab(): string|int|null + { + return 'open'; + } +} diff --git a/app/Admin/Resources/TicketResource/Widgets/TicketsOverView.php b/app/Admin/Resources/TicketResource/Widgets/TicketsOverView.php new file mode 100644 index 000000000..a56f4cc82 --- /dev/null +++ b/app/Admin/Resources/TicketResource/Widgets/TicketsOverView.php @@ -0,0 +1,24 @@ +count()) + ->color('success'), + Stat::make('Closed Tickets', Ticket::where('status', 'closed')->count()) + ->color('danger'), + Stat::make('Replied Tickets', Ticket::where('status', 'replied')->count()) + ->color('gray'), + ]; + } +} diff --git a/app/Admin/Resources/UserResource.php b/app/Admin/Resources/UserResource.php new file mode 100644 index 000000000..0eeb1812f --- /dev/null +++ b/app/Admin/Resources/UserResource.php @@ -0,0 +1,134 @@ +name; + } + + public static function form(Form $form): Form + { + return $form + ->schema([ + TextInput::make('first_name')->translateLabel()->required(), + TextInput::make('last_name')->translateLabel()->required(), + TextInput::make('email')->translateLabel()->email()->required()->unique('users', 'email', ignoreRecord: true), + + TextInput::make('password')->translateLabel()->password()->revealable() + ->dehydrateStateUsing(fn (string $state): string => Hash::make($state)) + ->dehydrated(fn (?string $state): bool => filled($state)) + ->required(fn (string $operation): bool => $operation === 'create'), + Select::make('role_id')->translateLabel()->relationship('role', 'name')->searchable()->preload(), + Toggle::make('tfa_secret') + ->label('Two Factor Authentication') + ->disabled(fn ($record) => !$record?->tfa_secret) + ->dehydrateStateUsing(fn ($state, $record) => $state ? $record->tfa_secret : null) + ->formatStateUsing(fn ($record) => $record && $record->tfa_secret ? true : false) + ->hiddenOn(['create']), + + Toggle::make('email_verified_at') + ->label('Email Verified') + ->dehydrateStateUsing(function ($state, $record) { + if ($state && !$record->email_verified_at) { + return now(); + } + + return $state ? $record->email_verified_at : null; + }) + ->hiddenOn(['create']), + ]); + } + + public static function table(Table $table): Table + { + return $table + ->columns([ + TextColumn::make('first_name') + ->searchable() + ->description(function (User $user) { + if (count($user->credits) <= 0) { + return null; + } + + return 'Earnings - ' . implode(', ', $user->credits->map(function (Credit $credit) { + return "$credit->currency_code: $credit->amount"; + })->toArray()); + }), + TextColumn::make('last_name')->searchable(), + TextColumn::make('email')->searchable(), + TextColumn::make('role.name'), + ]) + ->filters([ + Tables\Filters\SelectFilter::make('role') + ->relationship('role', 'name') + ->searchable() + ->preload(), + ]) + ->actions([ + Tables\Actions\EditAction::make(), + ]); + } + + public static function getRelations(): array + { + return [ + PropertiesRelationManager::class, + ]; + } + + public static function getPages(): array + { + return [ + 'index' => Pages\ListUsers::route('/'), + 'create' => Pages\CreateUser::route('/create'), + 'edit' => Pages\EditUser::route('/{record}/edit'), + 'services' => Pages\ShowServices::route('/{record}/services'), + 'invoices' => Pages\ShowInvoices::route('/{record}/invoices'), + 'credits' => Pages\ShowCredits::route('/{record}/credits'), + ]; + } + + public static function getRecordSubNavigation(Page $page): array + { + + return $page->generateNavigationItems([ + Pages\EditUser::class, + Pages\ShowServices::class, + Pages\ShowInvoices::class, + Pages\ShowCredits::class, + ]); + } +} diff --git a/app/Admin/Resources/UserResource/Pages/CreateUser.php b/app/Admin/Resources/UserResource/Pages/CreateUser.php new file mode 100644 index 000000000..f821d0ebf --- /dev/null +++ b/app/Admin/Resources/UserResource/Pages/CreateUser.php @@ -0,0 +1,11 @@ +label('All Tickets') + ->icon('ri-customer-service-fill') + ->url(fn ($record) => "/admin/tickets?tableSearch={$record->email}&activeTab=all", true) + ->openUrlInNewTab(), + Actions\DeleteAction::make() + ->icon('ri-delete-back-2-fill') + ->label('Delete User') + ->hidden(fn ($record) => Auth::user()->id == $record->id), + Actions\Action::make('impersonate') + ->label('Impersonate User') + ->icon('heroicon-s-user-circle') + ->action(function ($record) { + session()->put('impersonating', $record->id); + $this->redirect('/dashboard'); + }) + ->hidden(fn ($record) => Auth::user()->hasPermission('impersonate', $record) == false || Auth::user()->id == $record->id), + ]; + } +} diff --git a/app/Admin/Resources/UserResource/Pages/ListUsers.php b/app/Admin/Resources/UserResource/Pages/ListUsers.php new file mode 100644 index 000000000..d8bb89d66 --- /dev/null +++ b/app/Admin/Resources/UserResource/Pages/ListUsers.php @@ -0,0 +1,19 @@ +schema([ + Forms\Components\Select::make('currency_code') + ->options(function () { + $existing_currencies = $this->getOwnerRecord()->credits->pluck('currency_code'); + + return Currency::whereNotIn('code', $existing_currencies)->pluck('code', 'code'); + }) + ->live() + ->required(), + Forms\Components\TextInput::make('amount') + ->required() + ->label('Amount') + // Suffix based on chosen currency + ->prefix(fn (Get $get) => Currency::where('code', $get('currency_code'))->first()?->prefix) + ->suffix(fn (Get $get) => Currency::where('code', $get('currency_code'))->first()?->suffix) + ->live(onBlur: true) + ->mask(RawJs::make( + <<<'JS' + $money($input, '.', '', 2) + JS + )) + ->numeric() + ->minValue(0), + + ]); + } + + public function table(Table $table): Table + { + return $table + ->recordTitleAttribute('currency.code') + ->columns([ + Tables\Columns\TextColumn::make('currency.code'), + Tables\Columns\TextColumn::make('formattedAmount')->label('Formatted Amount'), + Tables\Columns\TextInputColumn::make('amount')->label('Amount'), + ]) + ->filters([]) + ->headerActions([ + Tables\Actions\CreateAction::make()->disabled(function () { + $existing_currencies = $this->getOwnerRecord()->credits->pluck('currency_code'); + + return count(Currency::whereNotIn('code', $existing_currencies)->pluck('code', 'code')) <= 0; + }), + ]) + ->actions([ + Tables\Actions\DeleteAction::make(), + ]); + } +} diff --git a/app/Admin/Resources/UserResource/Pages/ShowInvoices.php b/app/Admin/Resources/UserResource/Pages/ShowInvoices.php new file mode 100644 index 000000000..aec5856cc --- /dev/null +++ b/app/Admin/Resources/UserResource/Pages/ShowInvoices.php @@ -0,0 +1,56 @@ +recordTitleAttribute('product.name') + ->columns([ + Tables\Columns\TextColumn::make('id')->sortable(), + Tables\Columns\TextColumn::make('formattedTotal')->label('Total'), + Tables\Columns\TextColumn::make('status') + ->label('Status') + // Make first letter uppercase + ->formatStateUsing(fn (string $state): string => ucfirst($state)) + ->badge() + ->color(fn (string $state): string => match ($state) { + 'paid' => 'success', + 'pending' => 'warning', + default => 'danger', + }), + ]) + ->filters([ + SelectFilter::make('status') + ->label('Status') + ->options([ + 'paid' => 'Paid', + 'pending' => 'Pending', + 'cancelled' => 'Cancelled', + ]), + ]) + ->actions([ + Tables\Actions\ViewAction::make()->url(fn ($record) => InvoiceResource::getUrl('edit', ['record' => $record])), + ]); + } +} diff --git a/app/Admin/Resources/UserResource/Pages/ShowServices.php b/app/Admin/Resources/UserResource/Pages/ShowServices.php new file mode 100644 index 000000000..a11c1d6b2 --- /dev/null +++ b/app/Admin/Resources/UserResource/Pages/ShowServices.php @@ -0,0 +1,40 @@ +recordTitleAttribute('product.name') + ->columns([ + Tables\Columns\TextColumn::make('product.name'), + Tables\Columns\TextColumn::make('quantity'), + Tables\Columns\TextColumn::make('formattedPrice')->label('Price'), + ]) + ->filters([ + // + ]) + ->actions([ + Tables\Actions\ViewAction::make()->url(fn ($record) => ServiceResource::getUrl('edit', ['record' => $record])), + ]); + } +} diff --git a/app/Admin/Widgets/ActiveUsers.php b/app/Admin/Widgets/ActiveUsers.php new file mode 100644 index 000000000..1798144c5 --- /dev/null +++ b/app/Admin/Widgets/ActiveUsers.php @@ -0,0 +1,36 @@ +where('last_activity', '>=', now()->subMinutes(5)) + ->whereNotNull('user_id') + ->orderBy('last_activity', 'desc') + ->with('user'); + + $sessions = (clone $baseQuery)->limit(5)->get(); + $onlineCount = (clone $baseQuery)->count(); + + return view(static::$view, [ + 'sessions' => $sessions, + 'onlineCount' => $onlineCount, + ]); + } + + public static function canView(): bool + { + return auth()->user()->hasPermission('admin.widgets.active_users'); + } +} diff --git a/app/Admin/Widgets/Overview.php b/app/Admin/Widgets/Overview.php new file mode 100644 index 000000000..c53ed03c4 --- /dev/null +++ b/app/Admin/Widgets/Overview.php @@ -0,0 +1,71 @@ +getData(InvoiceTransaction::class, 'Revenue', 'amount'), + $this->getData(Ticket::class, 'Tickets'), + $this->getData(Service::class, 'Services'), + ]; + } + + private function getData($model, $name, $sum = false) + { + $model = $model instanceof Model ? get_class($model) : $model; + + $chart = Trend::model($model) + ->between( + start: now()->subMonth(), + end: now(), + ) + ->perDay(); + + if ($sum) { + $chart = $chart->sum($sum); + } else { + $chart = $chart->count(); + } + + $thisMonth = $chart->sum('aggregate'); + + $lastMonth = $model::query() + ->whereBetween('created_at', [now()->subMonths(2), now()->subMonth()]); + + if ($sum) { + $lastMonth = $lastMonth->sum($sum); + } else { + $lastMonth = $lastMonth->count(); + } + + $increase = $thisMonth - $lastMonth; + + $percentageIncrease = $lastMonth > 0 ? (($thisMonth - $lastMonth) / $lastMonth) * 100 : 0; + + return Stat::make($name, $thisMonth) + ->description($increase >= 0 ? 'Increased by ' . number_format($percentageIncrease, 2) . '% (this month)' : 'Decreased by ' . number_format($percentageIncrease, 2) . '% (this month)') + ->descriptionIcon($increase >= 0 ? 'heroicon-m-arrow-trending-up' : 'heroicon-m-arrow-trending-down') + ->chart($chart->map(fn (TrendValue $value) => $value->aggregate)->toArray()) + ->color($increase >= 0 ? 'success' : 'danger'); + } + + public static function canView(): bool + { + return auth()->user()->hasPermission('admin.widgets.overview'); + } +} diff --git a/app/Admin/Widgets/Revenue.php b/app/Admin/Widgets/Revenue.php new file mode 100644 index 000000000..2946b3110 --- /dev/null +++ b/app/Admin/Widgets/Revenue.php @@ -0,0 +1,118 @@ + 'Last 24 hours', + 'week' => 'Last 7 days', + 'month' => 'Last 30 days', + 'year' => 'Last 365 days', + ]; + } + + protected function getData(): array + { + $start = match ($this->filter) { + 'today' => now()->subDay(), + 'week' => now()->subWeek(), + 'month' => now()->subMonth(), + 'year' => now()->subYear(), + }; + + $end = now(); + + $per = match ($this->filter) { + 'today' => 'hour', + 'week' => 'day', + 'month' => 'day', + 'year' => 'month', + }; + + $revenue = Trend::model(InvoiceTransaction::class) + ->between( + start: $start, + end: $end, + ) + ->{'per' . ucfirst($per)}() + ->sum('amount'); + + $netRevenue = Trend::model(InvoiceTransaction::class) + ->between( + start: $start, + end: $end, + ) + ->{'per' . ucfirst($per)}() + ->sum('amount - fee'); + + $newOrders = Trend::model(Order::class) + ->between( + start: $start, + end: $end, + ) + ->{'per' . ucfirst($per)}() + ->count(); + + return [ + 'datasets' => [ + [ + 'label' => 'Revenue', + 'data' => $revenue->map(fn (TrendValue $value) => $value->aggregate)->toArray(), + 'backgroundColor' => '#3490dc', + 'borderColor' => '#3490dc', + ], + [ + 'label' => 'Net Revenue', + 'data' => $netRevenue->map(fn (TrendValue $value) => $value->aggregate)->toArray(), + 'backgroundColor' => '#38c172', + 'borderColor' => '#38c172', + ], + [ + 'label' => 'New Orders', + 'data' => $newOrders->map(fn (TrendValue $value) => $value->aggregate)->toArray(), + 'backgroundColor' => '#e3342f', + 'borderColor' => '#e3342f', + ], + ], + 'labels' => $revenue->map(fn (TrendValue $value) => $this->filter === 'today' ? Carbon::parse($value->date)->format('H:i') : Carbon::parse($value->date)->format('M d'))->toArray(), + ]; + } + + protected function getOptions(): array|RawJs|null + { + return [ + 'scales' => [ + 'y' => [ + 'beginAtZero' => true, + ], + ], + ]; + } + + protected function getType(): string + { + return 'line'; + } + + public static function canView(): bool + { + return auth()->user()->hasPermission('admin.widgets.revenue'); + } +} diff --git a/app/Admin/Widgets/Support.php b/app/Admin/Widgets/Support.php new file mode 100644 index 000000000..e528aa9dd --- /dev/null +++ b/app/Admin/Widgets/Support.php @@ -0,0 +1,51 @@ +query( + Ticket::query() + ->where('status', '!=', 'closed') + ->with('user') + ->withMax('messages', 'created_at') + ->orderByDesc('messages_max_created_at') + ->limit(5) + ) + ->columns([ + Tables\Columns\TextColumn::make('subject') + ->label('Subject'), + Tables\Columns\TextColumn::make('status') + ->label('Status') + ->badge() + ->color(fn (Ticket $record) => match ($record->status) { + 'open' => 'success', + 'closed' => 'danger', + 'replied' => 'warning', + }) + ->formatStateUsing(fn (string $state) => ucfirst($state)), + Tables\Columns\TextColumn::make('user.name') + ->label('User'), + Tables\Columns\TextColumn::make('created_at') + ->label('Created At'), + ]) + ->recordUrl(fn (Ticket $record) => TicketResource::getUrl('edit', ['record' => $record])) + ->paginated(false); + } + + public static function canView(): bool + { + return auth()->user()->hasPermission('admin.widgets.support'); + } +} diff --git a/app/Attributes/DisabledIf.php b/app/Attributes/DisabledIf.php new file mode 100644 index 000000000..7d1b803ab --- /dev/null +++ b/app/Attributes/DisabledIf.php @@ -0,0 +1,16 @@ + (object) $product, + 'plan' => (object) $plan, + 'configOptions' => (object) $configOptions, + 'checkoutConfig' => (object) $checkoutConfig, + 'price' => $price, + 'quantity' => $quantity, + ]; + } else { + $cart = self::get()->push((object) [ + 'product' => (object) $product, + 'plan' => (object) $plan, + 'configOptions' => (object) $configOptions, + 'checkoutConfig' => (object) $checkoutConfig, + 'price' => $price, + 'quantity' => $quantity, + ]); + } + + session(['cart' => $cart]); + + if (Session::has('coupon')) { + // Reapply coupon to the cart + try { + $coupon = Session::get('coupon'); + self::removeCoupon(); + self::applyCoupon($coupon->code); + } catch (DisplayException $e) { + // Ignore exception + } + } + + // Return index of the newly added item + return $key ?? $cart->count() - 1; + } + + public static function remove($index) + { + $cart = self::get(); + $cart->forget($index); + session(['cart' => $cart]); + } + + /** + * Validate if a coupon is valid for the current user and cart + * + * @param string $coupon_code + * @return \App\Models\Coupon + * + * @throws \App\Exceptions\DisplayException + */ + public static function validateCoupon($coupon_code) + { + $coupon = Coupon::where('code', $coupon_code)->first(); + + if (!$coupon) { + throw new DisplayException('Coupon code not found'); + } + + if ($coupon->expires_at && $coupon->expires_at->isPast()) { + throw new DisplayException('Coupon code has expired'); + } + if ($coupon->starts_at && $coupon->starts_at->isFuture()) { + throw new DisplayException('Coupon code is not active yet'); + } + if ($coupon->max_uses && $coupon->services->count() >= $coupon->max_uses) { + throw new DisplayException('Coupon code has reached its maximum uses'); + } + if (Auth::check() && $coupon->hasExceededMaxUsesPerUser(Auth::id())) { + throw new DisplayException('You have already used this coupon the maximum number of times allowed'); + } + + return $coupon; + } + + public static function applyCoupon($code) + { + $coupon = self::validateCoupon($code); + + $wasSuccessful = false; + $items = self::get()->map(function ($item) use ($coupon, &$wasSuccessful) { + if ($coupon->products->where('id', $item->product->id)->isEmpty() && $coupon->products->isNotEmpty()) { + return (object) $item; + } + $wasSuccessful = true; + $discount = 0; + if ($coupon->type === 'percentage') { + $discount = $item->price->price * $coupon->value / 100; + } elseif ($coupon->type === 'fixed') { + $discount = $coupon->value; + } else { + $discount = $item->price->setup_fee; + $item->price->setup_fee = 0; + } + if ($item->price->price < $discount) { + $discount = $item->price->price; + } + $item->price->setDiscount($discount); + $item->price->price -= $discount; + + return (object) $item; + }); + + session(['cart' => $items]); + Session::put(['coupon' => $coupon]); + + if ($wasSuccessful) { + return $items; + } else { + throw new DisplayException('Coupon code is not valid for any items in your cart'); + } + } + + /** + * Validates and refreshes the coupon in the session + * + * @return bool True if coupon is valid, false otherwise + */ + public static function validateAndRefreshCoupon() + { + if (!Session::has('coupon')) { + return true; + } + + try { + $coupon = Session::get('coupon'); + self::validateCoupon($coupon->code); + + return true; + } catch (DisplayException $e) { + // Coupon is invalid, remove it + self::removeCoupon(); + + return false; + } + } + + public static function removeCoupon() + { + Session::forget('coupon'); + $items = self::get()->map(function ($item) { + $item->price = new Price([ + 'price' => $item->price->original_price, + 'setup_fee' => $item->price->original_setup_fee, + 'currency' => $item->price->currency, + ], apply_exclusive_tax: true); + + return (object) $item; + }); + session(['cart' => $items]); + } +} diff --git a/app/Classes/Extension/Extension.php b/app/Classes/Extension/Extension.php new file mode 100644 index 000000000..12f406291 --- /dev/null +++ b/app/Classes/Extension/Extension.php @@ -0,0 +1,73 @@ +config)) { + // Check from which type its being called + $type = debug_backtrace()[1]['class']; + $type = str_replace('Paymenter\Extensions\\', '', $type); + $type = str_replace('\\' . class_basename(static::class), '', $type); + if (in_array($type, ['Servers', 'Gateways'])) { + $type = substr($type, 0, -1); + $type = ($type == 'Gateway') ? Gateway::class : Server::class; + $this->config = $type::where('extension', class_basename(static::class))->first()->settings->pluck('value', 'key')->toArray(); + } else { + $this->config = ModelsExtension::where('extension', class_basename(static::class))->first()->settings->pluck('value', 'key')->toArray(); + } + } + + return $this->config[$key] ?? null; + } + + /** + * Get the configuration fields for the extension + * + * @link https://docs.paymenter.org + * + * @param array $values The current values of the configuration (is empty on first load) + * @return array + */ + public function getConfig($values = []) + { + return []; + } + + /** + * Get the meta data for the extension + * E.g. name, description, version + * + * @example return [ + * 'name' => 'Paymenter', + * 'description' => 'Manage and optimize your hosting business with Paymenter', + * ] + * + * @link https://docs.paymenter.org + * + * @return array + */ + public function getMetadata() + { + return []; + } +} diff --git a/app/Classes/Extension/Gateway.php b/app/Classes/Extension/Gateway.php new file mode 100644 index 000000000..c6d7863f1 --- /dev/null +++ b/app/Classes/Extension/Gateway.php @@ -0,0 +1,8 @@ +type) { + case 'select': + return Select::make($setting->name) + ->label($setting->label ?? $setting->name) + ->helperText($setting->description ?? '') + ->options(function () use ($setting) { + /* Possiblities: + 1. ['value1', 'value2', 'value3'] + 2. ['value1' => 'label1', 'value2' => 'label2', 'value3' => 'label3'] + 3. [[ + 'value' => 'value1', + 'label' => 'label1', + ], [ + 'value' => 'value2', + 'label' => 'label2', + ]] + */ + if (isset($setting->options)) { + if (is_array($setting->options)) { + $options = []; + // Check if the keys are explicitly set or sequential + $keys = array_keys($setting->options); + $isSequential = $keys === range(0, count($keys) - 1); + + foreach ($setting->options as $key => $value) { + // Explicitly set keys (e.g., ['key1' => 'value1', 'key2' => 'value2']) + if (is_array($value)) { + $options[$value['value']] = $value['label']; + } else { + if ($isSequential) { + // Sequential keys (e.g., [0 => 'value1', 1 => 'value2']) + $options[$value] = $value; + } else { + $options[$key] = $value; + } + } + } + + return $options; + } else { + return (array) $setting->options; + } + } + + return []; + }) + ->preload() + ->multiple($setting->multiple ?? false) + ->required($setting->required ?? false) + ->hint($setting->hint ?? '') + ->hintColor('primary') + ->live(condition: $setting->live ?? false) + ->default($setting->default ?? '') + ->suffix($setting->suffix ?? null) + ->prefix($setting->prefix ?? null) + ->disabled($setting->disabled ?? false) + ->rules($setting->validation ?? []); + break; + + case 'tags': + return TagsInput::make($setting->name) + ->label($setting->label ?? $setting->name) + ->placeholder($setting->placeholder ?? '') + ->required($setting->required ?? false) + ->disabled($setting->disabled ?? false) + ->hint($setting->hint ?? '') + ->hintColor('primary') + ->helperText($setting->description ?? ''); + break; + + case 'text': + return TextInput::make($setting->name) + ->label($setting->label ?? $setting->name) + ->helperText($setting->description ?? '') + ->placeholder($setting->placeholder ?? $setting->default ?? '') + ->hint($setting->hint ?? '') + ->hintColor('primary') + ->required($setting->required ?? false) + ->live(condition: $setting->live ?? false) + ->default($setting->default ?? '') + ->suffix($setting->suffix ?? null) + ->prefix($setting->prefix ?? null) + ->disabled($setting->disabled ?? false) + ->rules($setting->validation ?? []); + break; + + case 'textarea': + return MarkdownEditor::make($setting->name) + ->label($setting->label ?? $setting->name) + ->helperText($setting->description ?? '') + ->placeholder($setting->placeholder ?? $setting->default ?? '') + ->hint($setting->hint ?? '') + ->hintColor('primary') + ->required($setting->required ?? false) + ->live(condition: $setting->live ?? false) + ->default($setting->default ?? '') + ->disableAllToolbarButtons($setting->disable_toolbar ?? false) + ->rules($setting->validation ?? []) + ->disabled($setting->disabled ?? false); + break; + case 'markdown': + return MarkdownEditor::make($setting->name) + ->label($setting->label ?? $setting->name) + ->helperText($setting->description ?? '') + ->placeholder($setting->placeholder ?? $setting->default ?? '') + ->hint($setting->hint ?? '') + ->hintColor('primary') + ->required($setting->required ?? false) + ->live(condition: $setting->live ?? false) + ->default($setting->default ?? '') + ->disableAllToolbarButtons($setting->disable_toolbar ?? false) + ->rules($setting->validation ?? []) + ->disabled($setting->disabled ?? false); + break; + case 'password': + return TextInput::make($setting->name) + ->label($setting->label ?? $setting->name) + ->helperText($setting->description ?? '') + ->placeholder($setting->placeholder ?? $setting->default ?? '') + ->hint($setting->hint ?? '') + ->hintColor('primary') + ->required($setting->required ?? false) + ->password() + ->revealable() + ->live(condition: $setting->live ?? false) + ->default($setting->default ?? '') + ->suffix($setting->suffix ?? null) + ->prefix($setting->prefix ?? null) + ->disabled($setting->disabled ?? false) + ->rules($setting->validation ?? []); + break; + case 'email': + return TextInput::make($setting->name) + ->label($setting->label ?? $setting->name) + ->helperText($setting->description ?? '') + ->placeholder($setting->placeholder ?? $setting->default ?? '') + ->hint($setting->hint ?? '') + ->hintColor('primary') + ->required($setting->required ?? false) + ->email() + ->live(condition: $setting->live ?? false) + ->default($setting->default ?? '') + ->suffix($setting->suffix ?? null) + ->prefix($setting->prefix ?? null) + ->disabled($setting->disabled ?? false) + ->rules($setting->validation ?? []); + break; + case 'number': + return TextInput::make($setting->name) + ->label($setting->label ?? $setting->name) + ->helperText($setting->description ?? '') + ->placeholder($setting->placeholder ?? $setting->default ?? '') + ->hint($setting->hint ?? '') + ->hintColor('primary') + ->required($setting->required ?? false) + ->numeric() + ->minValue($setting->min_value ?? null) + ->maxValue($setting->max_value ?? null) + ->live(condition: $setting->live ?? false) + ->default($setting->default ?? '') + ->suffix($setting->suffix ?? null) + ->prefix($setting->prefix ?? null) + ->disabled($setting->disabled ?? false) + ->rules($setting->validation ?? []); + + break; + case 'color': + $mode = $setting->color_mode ?? 'hsl'; + $color = ColorPicker::make($setting->name) + ->label($setting->label ?? $setting->name) + ->helperText($setting->description ?? '') + ->placeholder($setting->placeholder ?? $setting->default ?? '') + ->hint($setting->hint ?? '') + ->hintColor('primary') + ->required($setting->required ?? false) + ->live(condition: $setting->live ?? true) + ->default($setting->default ?? '') + ->suffix($setting->suffix ?? null) + ->prefix($setting->prefix ?? null) + ->disabled($setting->disabled ?? false) + ->rules($setting->validation ?? []) + ->rules(function () { + return function ($attribute, $value, $fail) { + try { + ColorFactory::fromString(trim($value)); + } catch (\Exception $e) { + $fail('The :attribute must be a valid color.'); + } + }; + }) + ->afterStateUpdated(function ($state, callable $set) use ($setting, $mode) { + try { + $set($setting->name, preg_replace('/,\s*/', ', ', ColorFactory::fromString(trim($state))->{'to' . ucfirst($mode)}()->__toString())); + } catch (\Exception $e) { + } + }); + $color->$mode(); + + return $color; + break; + case 'file': + $input = FileUpload::make($setting->name) + ->label($setting->label ?? $setting->name) + ->helperText($setting->description ?? '') + ->hint($setting->hint ?? '') + ->hintColor('primary') + ->required($setting->required ?? false) + ->acceptedFileTypes($setting->accept ?? []) + ->live(condition: $setting->live ?? false) + ->default($setting->default ?? '') + ->disk($setting->disk ?? 'public') + ->preserveFilenames($setting->preserve_filenames ?? true) + ->disabled($setting->disabled ?? false) + ->downloadable() + ->rules($setting->validation ?? []); + + if (isset($setting->file_name)) { + $input->getUploadedFileNameForStorageUsing( + fn (): string => (string) $setting->file_name, + ); + } + + return $input; + + break; + + case 'checkbox': + return Checkbox::make($setting->name) + ->label($setting->label ?? $setting->name) + ->helperText($setting->description ?? '') + ->required($setting->required ?? false) + ->hint($setting->hint ?? '') + ->hintColor('primary') + ->live(condition: $setting->live ?? false) + ->default($setting->default ?? '') + ->disabled($setting->disabled ?? false) + ->rules($setting->validation ?? []); + break; + + case 'placeholder': + return Placeholder::make($setting->name) + ->content($setting->label ?? null) + ->helperText($setting->description ?? '') + ->hint($setting->hint ?? '') + ->hintColor('primary'); + break; + + default: + throw new \Exception("Unknown input type: {$setting->type}"); + } + } +} diff --git a/app/Classes/Navigation.php b/app/Classes/Navigation.php new file mode 100644 index 000000000..f05cee7fc --- /dev/null +++ b/app/Classes/Navigation.php @@ -0,0 +1,261 @@ +where(function ($query) { + $query->whereHas('children')->orWhereHas('products', function ($query) { + $query->where('hidden', false); + }); + })->get(); + + $routes = [ + [ + 'name' => __('navigation.home'), + 'route' => 'home', + 'icon' => 'ri-home-2', + ], + [ + 'name' => __('navigation.shop'), + 'children' => $categories->map(function ($category) { + return [ + 'name' => $category->name, + 'route' => 'category.show', + 'params' => ['category' => $category->slug], + ]; + })->toArray(), + 'condition' => count($categories) > 0, + 'separator' => true, + 'icon' => 'ri-shopping-bag', + ], + ]; + + $routes = EventHelper::itemEvent('navigation', $routes); + + $routes = array_filter($routes, function ($route) { + return isset($route['condition']) ? $route['condition'] : true; + }); + + return Navigation::markActiveRoute($routes); + }); + } + + // Get navigation items for user dropdown menu + public static function getAccountDropdownLinks() + { + return once(function () { + + $routes = [ + [ + 'name' => __('navigation.dashboard'), + 'route' => 'dashboard', + ], + [ + 'name' => __('navigation.tickets'), + 'route' => 'tickets', + 'condition' => !config('settings.tickets_disabled', false), + ], + [ + 'name' => __('navigation.account'), + 'route' => 'account', + ], + [ + 'name' => __('navigation.admin'), + 'route' => 'filament.admin.pages.dashboard', + 'spa' => false, + 'condition' => Auth::user()->role_id !== null, + ], + ]; + + $routes = EventHelper::itemEvent('navigation.account-dropdown', $routes); + + $routes = array_filter($routes, function ($route) { + return isset($route['condition']) ? $route['condition'] : true; + }); + + return Navigation::markActiveRoute($routes); + }); + } + + public static function getDashboardLinks() + { + return once(function () { + + $routes = [ + [ + 'name' => __('navigation.dashboard'), + 'route' => 'dashboard', + 'icon' => 'ri-function', + 'condition' => Auth::check(), + 'priority' => 10, + ], + [ + 'name' => __('navigation.services'), + 'route' => 'services', + 'icon' => 'ri-archive-stack', + 'condition' => Auth::check(), + 'priority' => 20, + ], + [ + 'name' => __('navigation.invoices'), + 'route' => 'invoices', + 'icon' => 'ri-receipt', + 'separator' => true, + 'condition' => Auth::check(), + 'priority' => 30, + ], + [ + 'name' => __('navigation.tickets'), + 'route' => 'tickets', + 'icon' => 'ri-customer-service', + 'separator' => true, + 'condition' => Auth::check() && !config('settings.tickets_disabled', false), + 'priority' => 40, + ], + [ + 'name' => __('navigation.account'), + 'icon' => 'ri-settings-3', + 'condition' => Auth::check(), + 'priority' => 50, + 'children' => EventHelper::itemEvent( + 'navigation.account', + [ + [ + 'name' => __('navigation.personal_details'), + 'route' => 'account', + 'params' => [], + 'priority' => 10, + ], + [ + 'name' => __('navigation.security'), + 'route' => 'account.security', + 'params' => [], + 'priority' => 20, + ], + [ + 'name' => __('account.credits'), + 'route' => 'account.credits', + 'params' => [], + 'condition' => config('settings.credits_enabled'), + 'priority' => 30, + ], + ] + ), + ], + ]; + + $routes = EventHelper::itemEvent('navigation.dashboard', $routes); + + $routes = array_filter($routes, function ($route) { + return isset($route['condition']) ? $route['condition'] : true; + }); + + return Navigation::markActiveRoute($routes); + }); + } + + public static function getActiveRoute() + { + $route = request()->route()->getName(); + $routes = [ + ...self::getLinks(), + ...self::getAccountDropdownLinks(), + ...self::getDashboardLinks(), + ]; + // Get current parnet of the route + $parent = null; + foreach ($routes as $item) { + if ($item['route'] == $route) { + $parent = $item; + break; + } + if (isset($item['children'])) { + foreach ($item['children'] as $child) { + if ($child['route'] == $route) { + $parent = $item; + break; + } + } + } + } + + return $parent; + } + + /** + * Set `active` to true if the route is currently active, + * or falce if route isn't active (prevents `Undefined array key "active"` errors) + * + * @return array routes + */ + public static function markActiveRoute(array $routes): array + { + $currentRoute = request()->route()->getName(); + + foreach ($routes as &$route) { + $route['active'] = self::isActiveRoute($route, $currentRoute); + + if (isset($route['icon'])) { + $route['icon'] .= $route['active'] ? '-fill' : '-line'; + } + + if (isset($route['children'])) { + foreach ($route['children'] as &$child) { + $child['active'] = self::isActiveRoute($child, $currentRoute); + } + } + } + + return $routes; + } + + private static function isActiveRoute(array $route, string $currentRoute): bool + { + if (($route['route'] ?? '') === $currentRoute) { + return true; + } + + if (!empty($route['children'])) { + foreach ($route['children'] as $child) { + if (($child['route'] ?? '') === $currentRoute) { + return true; + } + } + } + + return false; + } + + public static function getCurrent() + { + $route = request()->route()->getName(); + $routes = self::getLinks(); + // Get current parnet of the route + $parent = null; + foreach ($routes as $item) { + if ($item['route'] == $route) { + $parent = $item; + break; + } + if (isset($item['children'])) { + foreach ($item['children'] as $child) { + if ($child['route'] == $route) { + $parent = $item; + break; + } + } + } + } + + return $parent; + } +} diff --git a/app/Classes/PDF.php b/app/Classes/PDF.php new file mode 100644 index 000000000..fd6b399be --- /dev/null +++ b/app/Classes/PDF.php @@ -0,0 +1,16 @@ + $invoice]); + + return $pdf; + } +} diff --git a/app/Classes/Price.php b/app/Classes/Price.php new file mode 100644 index 000000000..ae24c7a33 --- /dev/null +++ b/app/Classes/Price.php @@ -0,0 +1,147 @@ +discount = $discount; + } + + public function __construct($priceAndCurrency = null, $free = false, $dontShowUnavailablePrice = false, $apply_exclusive_tax = false) + { + if (is_array($priceAndCurrency)) { + $priceAndCurrency = (object) $priceAndCurrency; + } + if ($free) { + $this->price = 0; + $this->currency = $priceAndCurrency->currency ?? null; + $this->is_free = true; + + $this->formatted = (object) [ + 'price' => $this->format($this->price), + 'setup_fee' => $this->format($this->setup_fee), + 'tax' => $this->format($this->tax), + 'setup_fee_tax' => $this->format($this->setup_fee_tax), + ]; + + return; + } + + $this->price = $priceAndCurrency->price->price ?? $priceAndCurrency->price ?? null; + $this->currency = $priceAndCurrency->currency; + if (is_array($this->currency)) { + $this->currency = (object) $this->currency; + } + $this->setup_fee = $priceAndCurrency->price->setup_fee ?? $priceAndCurrency->setup_fee ?? null; + // We save the original so we can revert back to it when removing a coupon + $this->original_price = $this->price; + $this->original_setup_fee = $this->setup_fee; + + // Calculate taxes + if (config('settings.tax_enabled')) { + $tax = Settings::tax(); + if ($tax) { + // Inclusive has the tax included in the price + if (config('settings.tax_type') == 'inclusive' || !$apply_exclusive_tax) { + $this->tax = number_format($this->price - ($this->price / (1 + $tax->rate / 100)), 2, '.', ''); + if ($this->setup_fee) { + $this->setup_fee_tax = number_format($this->setup_fee - ($this->setup_fee / (1 + $tax->rate / 100)), 2, '.', ''); + } + } else { + // Exclusive has the tax added to the price as an extra + $this->tax = number_format($this->price * $tax->rate / 100, 2, '.', ''); + $this->original_price = $this->price + $this->tax; + $this->price = number_format($this->price + $this->tax, 2, '.', ''); + if ($this->setup_fee) { + $this->setup_fee_tax = number_format($this->setup_fee * $tax->rate / 100, 2, '.', ''); + $this->original_setup_fee = $this->setup_fee + $this->setup_fee_tax; + $this->setup_fee = number_format($this->setup_fee + $this->setup_fee_tax, 2, '.', ''); + } + } + } + } + $this->has_setup_fee = isset($this->setup_fee) ? $this->setup_fee > 0 : false; + $this->dontShowUnavailablePrice = $dontShowUnavailablePrice; + $this->formatted = (object) [ + 'price' => $this->format($this->price), + 'setup_fee' => $this->format($this->setup_fee), + 'tax' => $this->format($this->tax), + 'setup_fee_tax' => $this->format($this->setup_fee_tax), + ]; + } + + public function format($price) + { + if ($this->is_free) { + return 'Free'; + } + if (!$this->currency) { + if ($this->dontShowUnavailablePrice) { + return ''; + } + + return 'Not available in your currency'; + } + // Get the format + $format = $this->currency->format; + switch ($format) { + case '1.000,00': + $price = number_format($price, 2, ',', '.'); + break; + case '1,000.00': + $price = number_format($price, 2, '.', ','); + break; + case '1 000,00': + $price = number_format($price, 2, ',', ' '); + break; + case '1 000.00': + $price = number_format($price, 2, '.', ' '); + break; + } + + return $this->currency->prefix . $price . $this->currency->suffix; + } + + public function __toString() + { + return $this->formatted->price; + } + + public function __get($name) + { + if ($name == 'available') { + return $this->currency || $this->is_free ? true : false; + } else { + return $this->$name; + } + } +} diff --git a/app/Classes/Settings.php b/app/Classes/Settings.php new file mode 100644 index 000000000..bd56193b5 --- /dev/null +++ b/app/Classes/Settings.php @@ -0,0 +1,611 @@ +toArray(); + }); + } catch (\Exception $e) { + $currencies = []; + } + $settings = [ + // Split settings into groups (only used in the settings page for organization) + 'general' => [ + [ + 'name' => 'timezone', + 'label' => 'Timezone', + 'type' => 'select', + // Read timezones from PHP + 'options' => \DateTimeZone::listIdentifiers(\DateTimeZone::ALL), + 'default' => 'UTC', + 'required' => true, + 'override' => 'app.timezone', + ], + [ + 'name' => 'app_language', + 'label' => 'Default Language', + 'default' => 'en', + 'type' => 'select', + // Read languages from resources/lang directory + // The ternary operator is only present for now. Since there are no lang files, it returns [], which breaks the frontend, so we return ['en'] + 'options' => glob(base_path('lang/*'), GLOB_ONLYDIR) ? array_map('basename', glob(base_path('lang/*'), GLOB_ONLYDIR)) : ['en'], + 'required' => true, + 'validation' => 'in:' . implode(',', glob(base_path('lang/*'), GLOB_ONLYDIR) ? array_map('basename', glob(base_path('lang/*'), GLOB_ONLYDIR)) : ['en']), + 'override' => 'app.locale', + ], + [ + 'name' => 'app_url', + 'label' => 'App URL', + 'default' => 'http://localhost', + 'type' => 'text', + 'required' => true, + 'validation' => 'url', + 'override' => 'app.url', + ], + [ + 'name' => 'logo', + 'label' => 'Logo', + 'type' => 'file', + 'required' => false, + 'accept' => ['image/*'], + 'file_name' => 'logo.webp', + ], + [ + 'name' => 'tos', + 'label' => 'Terms of Service', + 'description' => 'URL to your terms of service. Leave blank to disable.', + 'type' => 'text', + 'required' => false, + ], + ], + + // Security (captcha, rate limiting, etc.) + 'security' => [ + [ + 'name' => 'captcha', + 'label' => 'Captcha', + 'type' => 'select', + 'options' => [ + 'disabled' => 'Disabled', + 'recaptcha-v2' => 'Google reCAPTCHA v2', + 'recaptcha-v3' => 'Google reCAPTCHA v3', + 'turnstile' => 'Cloudflare Turnstile', + 'hcaptcha' => 'hCaptcha', + ], + 'default' => 'disabled', + ], + [ + 'name' => 'captcha_site_key', + 'label' => 'Captcha Site Key', + 'type' => 'text', + 'required' => false, + ], + [ + 'name' => 'captcha_secret', + 'label' => 'Captcha Secret', + 'type' => 'text', + 'required' => false, + ], + + [ + 'name' => 'trusted_proxies', + 'label' => 'Trusted Proxies', + 'type' => 'tags', + 'database_type' => 'array', + 'placeholder' => 'IP Addresses or CIDR (e.g. 1.1.1.1/32 or 2606:4700:4700::1111)', + ], + ], + + 'social-login' => [ + [ + 'name' => 'oauth_google', + 'label' => 'Google Enabled', + 'description' => new HtmlString('Documentation'), + 'type' => 'checkbox', + 'database_type' => 'boolean', + 'default' => false, + 'required' => false, + ], + [ + 'name' => 'oauth_google_client_id', + 'label' => 'Google Client ID', + 'type' => 'text', + 'required' => false, + ], + [ + 'name' => 'oauth_google_client_secret', + 'label' => 'Google Client Secret', + 'type' => 'text', + 'required' => false, + ], + [ + 'name' => 'oauth_github', + 'label' => 'GitHub Enabled', + 'description' => new HtmlString('Documentation'), + 'type' => 'checkbox', + 'database_type' => 'boolean', + 'default' => false, + 'required' => false, + ], + [ + 'name' => 'oauth_github_client_id', + 'label' => 'Github Client ID', + 'type' => 'text', + 'required' => false, + ], + [ + 'name' => 'oauth_github_client_secret', + 'label' => 'Github Client Secret', + 'type' => 'text', + 'required' => false, + ], + [ + 'name' => 'oauth_discord', + 'label' => 'Discord Enabled', + 'description' => new HtmlString('Documentation'), + 'type' => 'checkbox', + 'database_type' => 'boolean', + 'default' => false, + 'required' => false, + ], + [ + 'name' => 'oauth_discord_client_id', + 'label' => 'Discord Client ID', + 'type' => 'text', + 'required' => false, + ], + [ + 'name' => 'oauth_discord_client_secret', + 'label' => 'Discord Client Secret', + 'type' => 'text', + 'required' => false, + ], + ], + + 'company-details' => [ + [ + 'name' => 'company_name', + 'label' => 'Company Name', + 'type' => 'text', + 'override' => 'app.name', + 'default' => 'Paymenter', + ], + [ + 'name' => 'company_email', + 'label' => 'Company Email', + 'type' => 'email', + ], + [ + 'name' => 'company_phone', + 'label' => 'Company Phone', + 'type' => 'text', + ], + [ + 'name' => 'company_address', + 'label' => 'Company Address', + 'type' => 'text', + ], + [ + 'name' => 'company_address2', + 'label' => 'Company Address 2', + 'type' => 'text', + ], + [ + 'name' => 'company_city', + 'label' => 'Company City', + 'type' => 'text', + ], + [ + 'name' => 'company_state', + 'label' => 'Company State', + 'type' => 'text', + ], + [ + 'name' => 'company_zip', + 'label' => 'Company Zip', + 'type' => 'text', + ], + [ + 'name' => 'company_country', + 'label' => 'Company Country', + 'type' => 'select', + 'options' => array_merge(['' => 'None'], config('app.countries')), + ], + [ + 'name' => 'company_tax_id', + 'label' => 'Company Tax ID', + 'type' => 'text', + ], + [ + 'name' => 'company_id', + 'label' => 'Company ID', + 'type' => 'text', + ], + ], + 'tax' => [ + [ + 'name' => 'tax_enabled', + 'label' => 'Tax Enabled', + 'type' => 'checkbox', + 'database_type' => 'boolean', + 'default' => false, + ], + [ + 'name' => 'tax_type', + 'label' => 'Tax Type', + 'type' => 'select', + 'options' => [ + 'inclusive' => 'Inclusive (Price includes tax)', + 'exclusive' => 'Exclusive (Price does not include tax)', + ], + 'default' => 'inclusive', + ], + ], + 'mail' => [ + // SMTP etc + [ + 'name' => 'mail_disable', + 'label' => 'Disable Mail', + 'type' => 'checkbox', + 'database_type' => 'boolean', + 'default' => true, + ], + [ + 'name' => 'mail_must_verify', + 'label' => 'Users must verify email before buying', + 'type' => 'checkbox', + 'database_type' => 'boolean', + 'default' => false, + ], + [ + 'name' => 'mail_host', + 'label' => 'Mail Host', + 'type' => 'text', + 'required' => false, + 'override' => 'mail.mailers.smtp.host', + ], + [ + 'name' => 'mail_port', + 'label' => 'Mail Port', + 'type' => 'text', + 'required' => false, + 'override' => 'mail.mailers.smtp.port', + ], + [ + 'name' => 'mail_username', + 'label' => 'Mail Username', + 'type' => 'text', + 'required' => false, + 'override' => 'mail.mailers.smtp.username', + ], + [ + 'name' => 'mail_password', + 'label' => 'Mail Password', + 'type' => 'password', + 'required' => false, + 'encrypted' => true, + 'override' => 'mail.mailers.smtp.password', + ], + [ + 'name' => 'mail_encryption', + 'label' => 'Mail Encryption', + 'type' => 'select', + 'options' => [ + 'tls' => 'TLS', + 'ssl' => 'SSL', + null => 'None', + ], + 'default' => 'tls', + 'required' => false, + 'override' => 'mail.mailers.smtp.encryption', + ], + [ + 'name' => 'mail_from_address', + 'label' => 'Mail From Address', + 'type' => 'email', + 'required' => false, + 'override' => 'mail.from.address', + ], + [ + 'name' => 'mail_from_name', + 'label' => 'Mail From Name', + 'type' => 'text', + 'required' => false, + 'override' => 'mail.from.name', + ], + + // Theming + [ + 'name' => 'mail_header', + 'label' => 'Header', + 'type' => 'markdown', + 'required' => false, + 'default' => '', + 'disable_toolbar' => true, + ], + [ + 'name' => 'mail_footer', + 'label' => 'Footer', + 'type' => 'markdown', + 'required' => false, + 'default' => '', + 'disable_toolbar' => true, + ], + [ + 'name' => 'mail_css', + 'label' => 'Mail CSS', + 'type' => 'markdown', + 'required' => false, + 'default' => '', + 'disable_toolbar' => true, + ], + ], + 'cronjob' => [ + [ + 'name' => 'cronjob_invoice', + 'label' => 'Send invoice if due date is x days away', + 'type' => 'number', + 'default' => 7, + 'required' => true, + ], + [ + 'name' => 'cronjob_invoice_reminder', + 'label' => 'Send invoice reminder if due date is x days away', + 'type' => 'number', + 'default' => 3, + 'required' => true, + ], + [ + // Cancel order is pending for x days + 'name' => 'cronjob_order_cancel', + 'label' => 'Cancel order if pending for x days', + 'type' => 'number', + 'default' => 7, + 'required' => true, + ], + [ + 'name' => 'cronjob_order_suspend', + 'label' => 'Suspend server if invoice is x days overdue', + 'type' => 'number', + 'default' => 2, + 'required' => true, + ], + [ + 'name' => 'cronjob_order_terminate', + 'label' => 'Delete server if invoice is x days overdue (also cancels the invoice)', + 'type' => 'number', + 'default' => 14, + 'required' => true, + ], + [ + 'name' => 'cronjob_delete_email_logs', + 'label' => 'Delete email logs older than x days', + 'type' => 'number', + 'default' => 90, + 'required' => true, + ], + [ + 'name' => 'cronjob_close_ticket', + 'label' => 'Close tickets if no response for x days', + 'type' => 'number', + 'default' => 7, + 'required' => true, + ], + ], + 'credits' => [ + [ + 'name' => 'credits_enabled', + 'label' => 'Credits Enabled', + 'type' => 'checkbox', + 'database_type' => 'boolean', + 'default' => false, + ], + [ + 'name' => 'credits_minimum_deposit', + 'label' => 'Minimum Deposit', + 'type' => 'number', + 'default' => 5, + 'required' => true, + ], + [ + 'name' => 'credits_maximum_deposit', + 'label' => 'Maximum Deposit', + 'type' => 'number', + 'default' => 100, + 'required' => true, + ], + [ + 'name' => 'credits_maximum_credit', + 'label' => 'Maximum Credit', + 'type' => 'number', + 'default' => 300, + 'required' => true, + ], + ], + 'theme' => [ + [ + 'name' => 'theme', + 'label' => 'Theme', + 'default' => 'default', + 'type' => 'select', + 'required' => true, + // Read themes from themes directory + 'options' => array_map('basename', glob(base_path('themes/*'), GLOB_ONLYDIR)), + 'validation' => 'in:' . implode(',', array_map('basename', glob(base_path('themes/*'), GLOB_ONLYDIR))), + ], + ], + 'invoices' => [ + [ + 'name' => 'invoice_number', + 'label' => 'Invoice Number', + 'type' => 'number', + 'default' => 1, + 'required' => false, + 'description' => 'The next invoice number to use. This will be incremented automatically.', + ], + [ + 'name' => 'invoice_number_padding', + 'label' => 'Invoice Number Padding', + 'type' => 'number', + 'default' => 1, + 'required' => false, + 'description' => 'Number of digits to use for invoice numbers. Example: 0001, 0002, etc.', + ], + [ + 'name' => 'invoice_number_format', + 'label' => 'Invoice number format', + 'type' => 'text', + 'default' => '{number}', + 'required' => false, + 'description' => 'Format to use for invoice numbers. Use {number} to insert the zero padded number and use {year}, {month} and {day} placeholders to insert the current date. Example: INV-{year}-{month}-{day}-{number} or INV-{year}{number}. It must at least contain {number}.', + 'validation' => 'regex:/{number}/', + ], + ], + 'other' => [ + [ + 'name' => 'gravatar_default', + 'label' => 'Gravatar Default', + 'description' => 'Default image to use when a user does not have a Gravatar. ', + 'link' => 'https://docs.gravatar.com/general/images/#default-image', + 'type' => 'select', + 'options' => [ + 'mp' => 'Mystery Person', + 'identicon' => 'Identicon', + 'monsterid' => 'Monster', + 'wavatar' => 'Wavatar', + 'retro' => 'Retro', + 'robohash' => 'Robohash', + 'blank' => 'Blank', + ], + 'default' => 'wavatar', + ], + [ + 'name' => 'default_currency', + 'label' => 'Default Currency', + 'type' => 'select', + 'options' => $currencies, + 'default' => 'USD', + 'required' => true, + ], + [ + 'name' => 'ticket_departments', + 'label' => 'Ticket Departments', + 'type' => 'tags', + 'default' => ['Support', 'Sales'], + 'required' => true, + 'database_type' => 'array', + ], + [ + 'name' => 'registration_disabled', + 'label' => 'Disable User Registration', + 'type' => 'checkbox', + 'database_type' => 'boolean', + 'default' => false, + 'description' => 'Only allow existing users to log in. This will hide the registration page and prevent new users from signing up.', + ], + [ + 'name' => 'tickets_disabled', + 'label' => 'Disable Tickets', + 'type' => 'checkbox', + 'database_type' => 'boolean', + 'default' => false, + 'description' => 'Disable the ticket system. This will disable all client side ticket functionality, including the ability to create new tickets and view existing tickets.', + ], + [ + 'name' => 'pagination', + 'label' => 'Pagination', + 'type' => 'number', + 'default' => 10, + 'required' => true, + 'description' => 'Number of items to show per page', + ], + [ + 'name' => 'debug', + 'label' => 'Debug Mode', + 'type' => 'checkbox', + 'database_type' => 'boolean', + 'default' => false, + 'description' => 'Enable debug mode to log HTTP requests and errors', + ], + ], + ]; + + // Set theme settings + $settings['theme'] = [...$settings['theme'], ...\App\Classes\Theme::getSettings()]; + + return $settings; + } + + public static function tax() + { + // Use once so the query is only run once + return once(function () { + $country = Auth::user()?->properties()->where('key', 'country')->value('value') ?? null; + + // Change country to a two-letter country code if it's not already + if ($country) { + $country = array_search($country, config('app.countries')) ?: $country; + } + + if ($taxRate = TaxRate::where('country', $country)->first()) { + return $taxRate; + } elseif ($taxRate = TaxRate::where('country', 'all')->first()) { + return $taxRate; + } + + return 0; + }); + } + + public static function settingsObject() + { + return (object) json_decode(json_encode(static::settings())); + } + + public static function getSetting($key) + { + $setting = (object) collect(static::settings())->flatten(1)->firstWhere('name', $key); + $setting->value = Setting::where('settingable_type', null)->where('key', $key)->value('value') ?? $setting->default ?? null; + + return $setting; + } + + public static function getTelemetry() + { + try { + $uuid = Setting::where('key', 'telemetry_uuid')->value('value'); + } catch (\Exception $e) { + $uuid = null; + } + if (is_null($uuid)) { + $uuid = Uuid::uuid4()->toString(); + try { + Setting::updateOrCreate( + ['key' => 'telemetry_uuid'], + ['value' => $uuid] + ); + } catch (\Exception $e) { + // Avoid errors in workflows + } + } + + // Daily fixed time based on UUID + $time = hexdec(str_replace('-', '', substr($uuid, 27))) % 1440; + $hour = floor($time / 60); + $minute = $time % 60; + + return compact('uuid', 'hour', 'minute'); + } +} diff --git a/app/Classes/Synths/PriceSynth.php b/app/Classes/Synths/PriceSynth.php new file mode 100644 index 000000000..69746bfb1 --- /dev/null +++ b/app/Classes/Synths/PriceSynth.php @@ -0,0 +1,49 @@ + $instance->price, + 'currency' => $instance->currency, + 'setup_fee' => $instance->setup_fee, + 'has_setup_fee' => $instance->has_setup_fee, + 'is_free' => $instance->is_free, + 'dontShowUnavailablePrice' => $instance->dontShowUnavailablePrice, + 'tax' => $instance->tax, + 'setup_fee_tax' => $instance->setup_fee_tax, + 'discount' => $instance->discount, + 'original_price' => $instance->original_price, + 'original_setup_fee' => $instance->original_setup_fee, + 'formatted' => $instance->formatted, + ], []]; + } + + public static function hydrate($instance) + { + $price = new Price(['price' => $instance['price'], 'setup_fee' => $instance['setup_fee'], 'currency' => $instance['currency']], $instance['is_free'], $instance['dontShowUnavailablePrice']); + $price->tax = $instance['tax']; + $price->setup_fee_tax = $instance['setup_fee_tax']; + $price->discount = $instance['discount']; + $price->original_price = $instance['original_price']; + $price->original_setup_fee = $instance['original_setup_fee']; + + return $price; + } +} diff --git a/app/Classes/Theme.php b/app/Classes/Theme.php new file mode 100644 index 000000000..baaeb645f --- /dev/null +++ b/app/Classes/Theme.php @@ -0,0 +1,29 @@ +_ + $settings = []; + foreach ($theme['settings'] as $setting) { + $setting['name'] = 'theme_' . config('settings.theme', 'default') . '_' . $setting['name']; + $settings[] = $setting; + } + + return $settings; + } +} diff --git a/app/Classes/helpers.php b/app/Classes/helpers.php new file mode 100644 index 000000000..decd3b85e --- /dev/null +++ b/app/Classes/helpers.php @@ -0,0 +1,33 @@ +info('You are using the development version. No update check available.'); + + return; + } + + if (config('app.version') == 'beta') { + $version = Http::get('https://api.paymenter.org/version?beta')->json(); + Setting::updateOrCreate( + ['key' => 'latest_commit'], + ['value' => $version['beta']] + ); + if (config('app.commit') != $version['beta']) { + $this->info('A new version is available: ' . $version['beta']); + // Save as a variable to use in the UI + $this->info('Latest version saved to database.'); + } else { + $this->info('You are using the latest version: ' . config('app.commit')); + } + } else { + $version = Http::get('https://api.paymenter.org/version')->json(); + + if (config('app.version') != $version['latest']) { + $this->info('A new version is available: ' . $version['latest']); + // Save as a variable to use in the UI + $this->info('Latest version saved to database.'); + } else { + $this->info('You are using the latest version: ' . config('app.version')); + } + } + Setting::updateOrCreate( + ['key' => 'latest_version'], + ['value' => $version['latest']] + ); + + $this->info('Update check completed.'); + } +} diff --git a/app/Console/Commands/CronJob.php b/app/Console/Commands/CronJob.php new file mode 100644 index 000000000..55fb7c3a9 --- /dev/null +++ b/app/Console/Commands/CronJob.php @@ -0,0 +1,160 @@ +where('expires_at', '<', now()->addDays((int) config('settings.cronjob_invoice', 7)))->get()->each(function ($service) use (&$sendedInvoices) { + // Does the service have already a pending invoice? + if ($service->invoices()->where('status', 'pending')->exists() || $service->cancellation()->exists()) { + return; + } + + DB::beginTransaction(); + + try { + // Calculate if we should edit the price because of the coupon + if ($service->coupon) { + // Calculate what iteration of the coupon we are in + $iteration = $service->invoices()->count() + 1; + if ($iteration == $service->coupon->recurring) { + // Calculate the price + $price = $service->plan->prices()->where('currency_code', $service->currency_code)->first()->price; + $service->price = $price; + $service->save(); + } + } + + // Create invoice + $invoice = $service->invoices()->make([ + 'user_id' => $service->user_id, + 'status' => 'pending', + 'due_at' => $service->expires_at, + 'currency_code' => $service->currency_code, + ]); + + $invoice->save(); + // Create invoice items + $invoice->items()->create([ + 'reference_id' => $service->id, + 'reference_type' => Service::class, + 'price' => $service->price, + 'quantity' => $service->quantity, + 'description' => $service->description, + ]); + } catch (\Exception $e) { + DB::rollBack(); + $this->error('Error creating invoice for service ' . $service->id . ': ' . $e->getMessage()); + + return; + } + + DB::commit(); + + $sendedInvoices++; + }); + $this->info('Sending invoices if due date is ' . config('settings.cronjob_invoice', 7) . ' days away: ' . $sendedInvoices . ' invoices'); + + // Cancel services if first invoice is not paid after x days + $ordersCancelled = 0; + Service::where('status', 'pending')->whereDoesntHave('invoices', function ($query) { + $query->where('status', 'paid'); + })->where('created_at', '<', now()->subDays((int) config('settings.cronjob_order_cancel', 7)))->get()->each(function ($service) use (&$ordersCancelled) { + $service->invoices()->where('status', 'pending')->update(['status' => 'cancelled']); + + $service->update(['status' => 'cancelled']); + + $ordersCancelled++; + }); + $this->info('Cancelling services if first invoice is not paid after ' . config('settings.cronjob_order_cancel', 7) . ' days: ' . $ordersCancelled . ' orders'); + + $updatedUpgradeInvoices = 0; + ServiceUpgrade::where('status', 'pending')->get()->each(function ($upgrade) use (&$updatedUpgradeInvoices) { + if ($upgrade->service->expires_at < now()) { + $upgrade->update(['status' => 'cancelled']); + $upgrade->invoice->update(['status' => 'cancelled']); + + $updatedUpgradeInvoices++; + + return; + } + + $upgrade->invoice->items()->update([ + 'price' => $upgrade->calculatePrice()->price, + ]); + + $updatedUpgradeInvoices++; + }); + + // Suspend orders if due date is overdue for x days + $ordersSuspended = 0; + Service::where('status', 'active')->where('expires_at', '<', now()->subDays((int) config('settings.cronjob_order_suspend', 2)))->each(function ($service) use (&$ordersSuspended) { + SuspendJob::dispatch($service); + + $service->update(['status' => 'suspended']); + $ordersSuspended++; + }); + $this->info('Suspending orders if due date is overdue for ' . config('settings.cronjob_order_suspend', 2) . ' days: ' . $ordersSuspended . ' orders'); + + // Terminate orders if due date is overdue for x days + $ordersTerminated = 0; + Service::where('status', 'suspended')->where('expires_at', '<', now()->subDays((int) config('settings.cronjob_order_terminate', 14)))->each(function ($service) use (&$ordersTerminated) { + TerminateJob::dispatch($service); + $service->update(['status' => 'cancelled']); + // Cancel outstanding invoices + $service->invoices()->where('status', 'pending')->update(['status' => 'cancelled']); + $ordersTerminated++; + }); + $this->info('Terminating orders if due date is overdue for ' . config('settings.cronjob_order_terminate', 14) . ' days: ' . $ordersTerminated . ' orders'); + + // Close tickets if no response for x days + $ticketClosed = 0; + Ticket::where('status', 'replied')->each(function ($ticket) use (&$ticketClosed) { + $lastMessage = $ticket->messages()->latest('created_at')->first(); + if ($lastMessage && $lastMessage->created_at < now()->subDays((int) config('settings.cronjob_close_ticket', 7))) { + $ticket->update(['status' => 'closed']); + $ticketClosed++; + } + }); + $this->info('Closing tickets if no response for ' . config('settings.cronjob_close_ticket', 7) . ' days: ' . $ticketClosed . ' tickets'); + + // Delete email logs older then x + $this->info('Deleting email logs older then ' . config('settings.cronjob_delete_email_logs', 90) . ' days: ' . EmailLog::where('created_at', '<', now()->subDays((int) config('settings.cronjob_delete_email_logs', 30)))->count()); + EmailLog::where('created_at', '<', now()->subDays((int) config('settings.cronjob_delete_email_logs', 90)))->delete(); + + // Check for updates + $this->info('Checking for updates...'); + + $this->call(CheckForUpdates::class); + } +} diff --git a/app/Console/Commands/Extension/Create.php b/app/Console/Commands/Extension/Create.php new file mode 100644 index 000000000..c95d1f520 --- /dev/null +++ b/app/Console/Commands/Extension/Create.php @@ -0,0 +1,72 @@ +argument('name'); + $type = $this->argument('type'); + if (!in_array($type, ['server', 'gateway', 'other'])) { + $this->error('Invalid extension type. Valid types are: server, gateway, event'); + + return; + } + // Read stub file contents + $stub = file_get_contents(__DIR__ . '/stubs/' . $type . '.stub'); + // Replace placeholders with actual values + $stub = str_replace('{{ class }}', ucfirst($name), $stub); + $stub = str_replace('{{ namespace }}', 'Paymenter\\Extensions\\' . ucfirst($type) . 's' . '\\' . ucfirst($name), $stub); + + // Create the extension file + $path = base_path('extensions/' . ucfirst($type) . 's/' . ucfirst($name) . '/' . ucfirst($name) . '.php'); + if (file_exists(dirname($path)) && !$this->option('force')) { + $this->error("Extension already exists\nRerun the command with a different name or use --force to overwrite"); + + return; + } + if (!file_exists(dirname($path))) { + mkdir(dirname($path), 0755, true); + } + file_put_contents($path, $stub); + + // Return path + $this->info("Extension created at: {$path}"); + } + + /** + * Prompt for missing input arguments using the returned questions. + * + * @return array + */ + protected function promptForMissingArgumentsUsing() + { + return [ + 'name' => 'What is the name of the extension?', + 'type' => function () { + $type = $this->choice('What type of extension?', ['server', 'gateway', 'other']); + + return $type; + }, + ]; + } +} diff --git a/app/Console/Commands/Extension/Disable.php b/app/Console/Commands/Extension/Disable.php new file mode 100644 index 000000000..2c25d43d5 --- /dev/null +++ b/app/Console/Commands/Extension/Disable.php @@ -0,0 +1,54 @@ +argument('name'); + $extension = Extension::where('extension', $name)->first(); + if (!$extension) { + $this->error('Extension not found'); + + return; + } + $extension->update(['enabled' => false]); + $this->info('Extension disabled'); + } + + /** + * Prompt for missing input arguments using the returned questions. + * + * @return array + */ + protected function promptForMissingArgumentsUsing() + { + return [ + 'name' => fn () => select( + label: 'Which extension do you want to disable?', + options: Extension::all()->pluck('extension', 'extension')->toArray(), + ), + ]; + } +} diff --git a/app/Console/Commands/Extension/stubs/gateway.stub b/app/Console/Commands/Extension/stubs/gateway.stub new file mode 100644 index 000000000..87e832039 --- /dev/null +++ b/app/Console/Commands/Extension/stubs/gateway.stub @@ -0,0 +1,32 @@ +info('Thanks for installing Paymenter!'); + + // Validate the URL + if (!str_starts_with($this->argument('url'), 'http')) { + $this->error('The URL must start with http or https.'); + + return; + } + + Setting::updateOrCreate(['key' => 'company_name'], ['value' => $this->argument('name')]); + Setting::updateOrCreate(['key' => 'app_url'], ['value' => rtrim($this->argument('url'), '/')]); + + $this->info("Now you're all set up!\nVisit Paymenter at " . $this->argument('url')); + } + + protected function promptForMissingArgumentsUsing(): array + { + return [ + 'name' => 'What is the name of your company?', + 'url' => fn () => text('What is the URL of your application?', required: true, validate: function ($value) { + return str_starts_with($value, 'http') ? null : 'The URL must start with http or https.'; + }), + ]; + } +} diff --git a/app/Console/Commands/Logs.php b/app/Console/Commands/Logs.php new file mode 100644 index 000000000..266140b6c --- /dev/null +++ b/app/Console/Commands/Logs.php @@ -0,0 +1,86 @@ +format('Y-m-d'); + $logFile = storage_path("logs/laravel-{$today}.log"); + if (!file_exists($logFile)) { + $this->info('No log file found for today.'); + + return; + } + + $logContents = file_get_contents($logFile); + // Find last error message + $lastErrorMessage = $this->getLastErrorMessage($logContents); + + if (!$lastErrorMessage) { + return $this->info('No error message found.'); + } + + if (!$this->confirm('Do you want to upload the error log (including environment variables) to Paymenter Support?', true)) { + $this->info('Here is the last error message:'); + // output the last error message + $this->line($lastErrorMessage); + + return; + } + + // Add paymenter version and php version as first lines to $lastErrorMessage + $paymenterVersion = config('app.version'); + $phpVersion = phpversion(); + $lastErrorMessage = "Paymenter Version: $paymenterVersion\nPHP Version: $phpVersion\nURL: " . url('/') . "\n\n$lastErrorMessage"; + + // Post the error message to Paymenter Support + // nc log.paymenter.org 99 + $this->info('Found error, uploading to Paymenter Support...'); + // Create a socket connection + $socket = socket_create(AF_INET, SOCK_STREAM, SOL_TCP); + socket_connect($socket, 'log.paymenter.org', 99); + socket_write($socket, $lastErrorMessage, strlen($lastErrorMessage)); + + // Read response from the server (up to 1024 bytes) + $response = socket_read($socket, 1024); + socket_close($socket); + + $this->line(trim($response)); + } + + protected function getLastErrorMessage(string $logContents): ?string + { + // [2025-07-03 11:06:06] local.ERROR: syntax error, unexpected token "if" {"exception":"[object] (ParseError(code: 0): syntax error, unexpected token \"if\" at C:\\Users\\corwi\\Projects\\Paymenter\\Paymenter\\app\\Listeners\\InvoiceItemCreatedListener.php:18) + $pattern = '/\[\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\].*?\.ERROR:(.*?)(?=\n\[\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\]|$)/s'; + preg_match_all($pattern, $logContents, $matches); + + if (!empty($matches[1])) { + // Return the last error message trimmed + return trim(end($matches[1])); + } + + return null; + } +} diff --git a/app/Console/Commands/MakeTheme.php b/app/Console/Commands/MakeTheme.php new file mode 100644 index 000000000..23f173d8b --- /dev/null +++ b/app/Console/Commands/MakeTheme.php @@ -0,0 +1,105 @@ +argument('name')))); + if ($this->themeExists($theme_name)) { + return $this->error("Theme \"$theme_name\" already exists."); + } + + $author = $this->argument('author'); + + $themes_directory = 'themes/'; + + $fs = new Filesystem; + + // Copy files from `default` theme + $fs->copyDirectory( + $themes_directory . 'default', + $themes_directory . $theme_name + ); + $this->line('[1/4] Copied files from `default` theme.'); + + // Replace all themes/default to themes/$theme_name in config files + // `/` at the end is present because we don't want to replace `default` in `export default defineConfig` + $fs->replaceInFile( + 'default/', + "$theme_name/", + $themes_directory . $theme_name . DIRECTORY_SEPARATOR . 'vite.config.js', + ); + + $this->line('[2/4] Replaced path values in vite.config.js.'); + + // Update author and description fields in new `theme.php` + $theme_file = $themes_directory . $theme_name . DIRECTORY_SEPARATOR . 'theme.php'; + // Get the contents of the file + $theme_file_contents = $fs->get($theme_file); + // Replace theme name and author + $theme_file_contents = str_replace( + ["'name' => 'Default'", "'author' => 'Paymenter'"], + ["'name' => '$theme_name'", "'author' => '$author'"], + $theme_file_contents + ); + + // Save the changes back to the file + $fs->put($theme_file, $theme_file_contents); + + $this->line('[3/4] Replaced variables in `theme.php`.'); + + $this->info("[4/4] Theme \"$theme_name\" created successfully."); + $this->newLine(); + + $this->comment('You can now start developing your theme by running:'); + $this->comment(" `npm run dev $theme_name`"); + $this->comment('After you have finished developing, you can build your theme by running:'); + $this->comment(" `npm run build $theme_name`"); + + return Command::SUCCESS; + } + + protected function themeExists(string $theme): bool + { + $directory = 'themes/' . $theme; + + if (is_dir($directory)) { + return true; + } + + return false; + } + + protected function promptForMissingArgumentsUsing() + { + return [ + 'name' => ['What should be the Theme\'s name?', 'E.g. My Theme'], + 'author' => ['Who is the author of this theme?', 'E.g. John Doe '], + ]; + } +} diff --git a/app/Console/Commands/MigrateOldData.php b/app/Console/Commands/MigrateOldData.php new file mode 100644 index 000000000..fdc02df47 --- /dev/null +++ b/app/Console/Commands/MigrateOldData.php @@ -0,0 +1,1485 @@ +argument('dbname'); + $host = $this->askOrUseENV(argument: 'host', env: 'DB_HOST', question: 'Enter the host:', placeholder: 'localhost'); + $port = $this->askOrUseENV(argument: 'port', env: 'DB_PORT', question: 'Enter the port:', placeholder: '3306'); + $username = $this->askOrUseENV(argument: 'username', env: 'DB_USERNAME', question: 'Enter the username:', placeholder: 'paymenter'); + $password = password("Enter the password for user '$username':", required: true); + + try { + $this->pdo = new PDO("mysql:host=$host;port=$port;dbname=$dbname", $username, $password); + $this->pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); + $this->pdo->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC); + $this->info('Connected to old database, Starting migration...'); + + DB::statement('SET foreign_key_checks=0'); + + $this->defaultCurrency(); + $this->migrateSettings(); + $this->migrateConfigOptions(); + $this->migrateCoupons(); + $this->migrateCategories(); + $this->migrateUsers(); + $this->migrateTickets(); + $this->migrateTicketMessages(); + $this->migrateTaxRates(); + $this->migrateExtensions(); + $this->migrateProducts(); + $this->migrateProductUpgrades(); + $this->migrateConfigOptionProducts(); + $this->migratePlans(); + $this->migrateOrdersAndServices(); + $this->migrateServiceConfigs(); + $this->migrateServiceCancellations(); + $this->migrateInvoices(); + + DB::statement('SET foreign_key_checks=1'); + + SettingsProvider::flushCache(); + } catch (PDOException $e) { + $this->fail('Connection failed: ' . $e->getMessage()); + } + } + + protected function askOrUseENV(string $argument, string $env, string $question, string $placeholder): string + { + $arg_value = $this->argument($argument); + if ($arg_value) { + return $arg_value; + } + + $env_value = env($env); + if (!is_null($env_value) && $env_value !== '') { + return $env_value; + } + + return text($question, required: true, placeholder: $placeholder); + } + + protected function migrateInBatch(string $table, string $query, Closure $processor) + { + /** + * @var PDOStatement $stmt + */ + $stmt = $this->pdo->prepare($query); + + $offset = 0; + $stmt->bindValue(':offset', $offset, PDO::PARAM_INT); + $stmt->bindValue(':limit', $this->batchSize, PDO::PARAM_INT); + $stmt->execute(); + + $nRows = $this->pdo->query('SELECT COUNT(*) FROM ' . $table)->fetchColumn(); + + if ($nRows <= $this->batchSize) { + $records = $stmt->fetchAll(PDO::FETCH_ASSOC); + try { + $processor($records); + } catch (\Throwable $th) { + Log::error($th); + $this->fail($th->getMessage()); + } + } else { + $bar = $this->output->createProgressBar(round($nRows / $this->batchSize)); + $bar->setFormat("Batch: %current%/%max% [%bar%] %percent:3s%%\n"); + $bar->start(); + while ($records = $stmt->fetchAll(PDO::FETCH_ASSOC)) { + $offset += $this->batchSize; + $stmt->bindValue(':offset', $offset, PDO::PARAM_INT); + $stmt->execute(); + + try { + $processor($records); + } catch (\Throwable $th) { + Log::error($th); + $this->fail($th->getMessage()); + } + + $bar->advance(); + } + + $bar->finish(); + } + + $this->info('Done.'); + } + + protected function defaultCurrency() + { + // Get default currency + $currency_settings = $this->pdo->query("SELECT * FROM `settings` WHERE `key` = 'currency' or `key` = 'currency_sign' or `key` = 'currency_position'")->fetchAll(); + $currency_settings = array_combine(array_column($currency_settings, 'key'), $currency_settings); + $this->currency_code = $currency_settings['currency']['value']; + + // Remove all the pre-existing currencies, in case the user still want's to use single currency + Currency::truncate(); + Currency::create([ + 'code' => $this->currency_code, + 'prefix' => $currency_settings['currency_position']['value'] === 'left' ? $currency_settings['currency_sign']['value'] : null, + 'suffix' => $currency_settings['currency_position']['value'] === 'right' ? $currency_settings['currency_sign']['value'] : null, + 'format' => '1,000.00', + ]); + } + + protected function migrateSettings() + { + $stmt = $this->pdo->query('SELECT * FROM settings'); + $records = $stmt->fetchAll(); + + // Map of settings which are just renamed + $old_to_new_map = [ + // General + 'timezone' => 'timezone', + 'language' => 'app_language', + 'app_logo' => 'logo', + 'home_page_text' => 'theme_default_home_page_text', + + // Security + 'recaptcha_site_key' => 'captcha_site_key', + 'recaptcha_secret_key' => 'captcha_secret', + + // Social Login + 'google_enabled' => 'oauth_google', + 'google_client_id' => 'oauth_google_client_id', + 'google_client_secret' => 'oauth_google_client_secret', + 'github_enabled' => 'oauth_github', + 'github_client_id' => 'oauth_github_client_id', + 'github_client_secret' => 'oauth_github_client_secret', + 'discord_enabled' => 'oauth_discord', + 'discord_client_id' => 'oauth_discord_client_id', + 'discord_client_secret' => 'oauth_discord_client_secret', + + // Company Details + 'company_name' => 'company_name', + 'company_email' => 'company_email', + 'company_phone' => 'company_phone', + 'company_address' => 'company_address', + 'company_city' => 'company_city', + 'company_zip' => 'company_zip', + + // Tax + 'tax_enabled' => 'tax_enabled', + 'tax_type' => 'tax_type', + + // Mail + 'mail_disabled' => 'mail_disable', + 'must_verify_email' => 'mail_must_verify', + 'mail_host' => 'mail_host', + 'mail_port' => 'mail_port', + 'mail_username' => 'mail_username', + 'mail_password' => 'mail_password', + 'mail_encryption' => 'mail_encryption', + 'mail_from_address' => 'mail_from_address', + 'mail_from_name' => 'mail_from_name', + + // Other + 'currency' => 'default_currency', + ]; + + $settings = []; + foreach ($records as $old_setting) { + $key = $old_to_new_map[$old_setting['key']] ?? $old_setting['key']; + $value = $old_setting['value']; + + // Migrate old settings directly if it is only renamed + if (array_key_exists($old_setting['key'], $old_to_new_map)) { + $avSetting = \App\Classes\Settings::getSetting($key); + + $settings[] = [ + 'key' => $key, + 'value' => $value, + 'type' => $avSetting->database_type ?? 'string', + 'settingable_type' => null, + 'settingable_id' => null, + 'encrypted' => $avSetting->encrypted ?? false, + 'created_at' => $old_setting['created_at'], + 'updated_at' => $old_setting['updated_at'], + ]; + } else { + // Manually migrate completely or partially changed settings + if ($key === 'recaptcha_type') { + $setting_id = array_search('recaptcha', array_column($records, 'key')); + $captcha_disabled = $records[$setting_id]['value'] === '0'; + + $settings[] = [ + 'key' => 'captcha', + 'value' => $captcha_disabled ? 'disabled' : match ($value) { + 'v2' => 'recaptcha-v2', + 'v3' => 'recaptcha-v3', + default => $value + }, + 'type' => 'string', + 'settingable_type' => null, + 'settingable_id' => null, + 'encrypted' => false, + 'created_at' => $old_setting['created_at'], + 'updated_at' => $old_setting['updated_at'], + ]; + } elseif ($key === 'company_country') { + $settings[] = [ + 'key' => $key, + 'value' => array_flip((array) config('app.countries'))[$value], + 'type' => 'string', + 'settingable_type' => null, + 'settingable_id' => null, + 'encrypted' => false, + 'created_at' => $old_setting['created_at'], + 'updated_at' => $old_setting['updated_at'], + ]; + } elseif (in_array($key, [ + 'requiredClientDetails_address', + 'requiredClientDetails_city', + 'requiredClientDetails_zip', + 'requiredClientDetails_country', + 'requiredClientDetails_phone', + ])) { + $key = str_replace('requiredClientDetails_', '', $key); + $property = CustomProperty::where('name', $key)->first(); + if ($property) { + $property->update(['required' => $value === '1']); + } + } + } + } + + foreach ($settings as $value) { + DB::table('settings')->updateOrInsert(['key' => $value['key']], $value); + } + $this->info('Migrated settings!'); + } + + protected function migrateConfigOptions() + { + $this->info('Migrating Config Options...'); + $this->migrateInBatch('configurable_options', 'SELECT c.id, c.name, c.type, c.order, c.hidden, c.group_id, g.name AS group_name, g.description AS group_description, g.products, c.created_at, c.updated_at FROM configurable_options as c JOIN configurable_option_groups as g ON c.group_id = g.id LIMIT :limit OFFSET :offset', function ($records) { + $records = array_map(function ($record) { + $option = explode('|', $record['name'], 2); + $env_variable = $option[0]; + $name = $option[1] ?? $env_variable; + + return [ + 'id' => $record['id'], + 'name' => trim($name), + 'env_variable' => $env_variable ? trim($env_variable) : trim($name), + 'type' => match ($record['type']) { + 'quantity' => 'number', + 'slider' => 'select', + default => $record['type'] + }, + // TODO: migrate sort, or not + 'sort' => null, + 'hidden' => $record['hidden'], + 'parent_id' => null, + + 'created_at' => $record['created_at'], + 'updated_at' => $record['updated_at'], + ]; + }, $records); + + DB::table('config_options')->insert($records); + }); + + $this->migrateInBatch('configurable_option_inputs', 'SELECT * FROM configurable_option_inputs LIMIT :limit OFFSET :offset', function ($option_inputs) { + $option_inputs = array_map(function ($record) { + $option = explode('|', $record['name'], 2); + $env_variable = $option[0]; + $name = $option[1] ?? $env_variable; + + return [ + 'id' => $record['id'], + 'name' => trim($name), + 'env_variable' => $env_variable ? trim($env_variable) : trim($name), + 'type' => null, + // TODO: migrate sort, or not + 'sort' => null, + 'hidden' => $record['hidden'], + + 'parent_id' => $record['option_id'], + 'created_at' => $record['created_at'], + 'updated_at' => $record['updated_at'], + ]; + }, $option_inputs); + + foreach ($option_inputs as $input) { + + $input_id = $input['id']; + $inputs_stmt = $this->pdo->query("SELECT * FROM configurable_option_input_pricing WHERE `input_id` = $input_id"); + $record = $inputs_stmt->fetchAll()[0]; + + if ( + is_null($record['monthly']) && + is_null($record['quarterly']) && + is_null($record['semi_annually']) && + is_null($record['annually']) && + is_null($record['biennially']) && + is_null($record['triennially']) + ) { + unset($input['id']); + $new_id = DB::table('config_options')->insertGetId($input); + + // Option is free + $input_plan = [ + 'name' => 'Free', + 'type' => 'free', + 'priceable_id' => $new_id, + 'priceable_type' => 'App\Models\ConfigOption', + ]; + + $plan_id = DB::table('plans')->insertGetId($input_plan); + + continue; + } + + if ( + $record['monthly'] && + is_null($record['quarterly']) && + is_null($record['semi_annually']) && + is_null($record['annually']) && + is_null($record['biennially']) && + is_null($record['triennially']) + ) { + unset($input['id']); + $new_id = DB::table('config_options')->insertGetId($input); + + // Option is one-time + $input_plan = [ + 'name' => 'One Time', + 'type' => 'one-time', + 'priceable_id' => $new_id, + 'priceable_type' => 'App\Models\ConfigOption', + ]; + + $plan_id = DB::table('plans')->insertGetId($input_plan); + + DB::table('prices')->insert([ + 'plan_id' => $plan_id, + 'price' => $record['monthly'], + 'setup_fee' => $record['monthly_setup'], + 'currency_code' => $this->currency_code, + ]); + + continue; + } + + if ( + $record['monthly'] && + is_null($record['quarterly']) && + is_null($record['semi_annually']) && + is_null($record['annually']) && + is_null($record['biennially']) && + is_null($record['triennially']) + ) { + unset($input['id']); + $new_id = DB::table('config_options')->insertGetId($input); + + // Option is one-time + $input_plan = [ + 'name' => 'One Time', + 'type' => 'one-time', + 'priceable_id' => $new_id, + 'priceable_type' => 'App\Models\ConfigOption', + ]; + + $plan_id = DB::table('plans')->insertGetId($input_plan); + + DB::table('prices')->insert([ + 'plan_id' => $plan_id, + 'price' => $record['monthly'], + 'setup_fee' => $record['monthly_setup'], + 'currency_code' => $this->currency_code, + ]); + + continue; + } + + if ( + $record['monthly'] && + ($record['quarterly'] || + $record['semi_annually'] || + $record['annually'] || + $record['biennially'] || + $record['triennially'] + ) + ) { + unset($input['id']); + $new_id = DB::table('config_options')->insertGetId($input); + + $common_fields = [ + 'type' => 'recurring', + 'priceable_id' => $new_id, + 'priceable_type' => 'App\Models\ConfigOption', + ]; + + $plans = []; + + if ($record['monthly']) { + array_push($plans, array_merge([ + 'name' => 'Monthly', + 'billing_period' => 1, + 'billing_unit' => 'month', + 'price' => [ + 'price' => $record['monthly'], + 'setup_fee' => $record['monthly_setup'], + 'currency_code' => $this->currency_code, + ], + ], $common_fields)); + } + + if ($record['quarterly']) { + array_push($plans, array_merge([ + 'name' => 'Quarterly', + 'billing_period' => 3, + 'billing_unit' => 'month', + 'price' => [ + 'price' => $record['quarterly'], + 'setup_fee' => $record['quarterly_setup'], + 'currency_code' => $this->currency_code, + ], + ], $common_fields)); + } + + if ($record['semi_annually']) { + array_push($plans, array_merge([ + 'name' => 'Semi-Annually', + 'billing_period' => 6, + 'billing_unit' => 'month', + 'price' => [ + 'price' => $record['semi_annually'], + 'setup_fee' => $record['semi_annually_setup'], + 'currency_code' => $this->currency_code, + ], + ], $common_fields)); + } + + if ($record['annually']) { + array_push($plans, array_merge([ + 'name' => 'Annually', + 'billing_period' => 1, + 'billing_unit' => 'year', + 'price' => [ + 'price' => $record['annually'], + 'setup_fee' => $record['annually_setup'], + 'currency_code' => $this->currency_code, + ], + ], $common_fields)); + } + + if ($record['biennially']) { + array_push($plans, array_merge([ + 'name' => 'Biennially', + 'billing_period' => 2, + 'billing_unit' => 'year', + 'price' => [ + 'price' => $record['biennially'], + 'setup_fee' => $record['biennially_setup'], + 'currency_code' => $this->currency_code, + ], + ], $common_fields)); + } + + if ($record['triennially']) { + array_push($plans, array_merge([ + 'name' => 'Triennially', + 'billing_period' => 3, + 'billing_unit' => 'year', + 'price' => [ + 'price' => $record['triennially'], + 'setup_fee' => $record['triennially_setup'], + 'currency_code' => $this->currency_code, + ], + ], $common_fields)); + } + + $all_prices = []; + foreach ($plans as $plan) { + $price = $plan['price']; + // Unset the price from the plan array, so it can be inserted without errors + unset($plan['price']); + $plan_id = DB::table('plans')->insertGetId($plan); + $all_prices[] = array_merge([ + 'plan_id' => $plan_id, + ], $price); + } + DB::table('prices')->insert($all_prices); + + continue; + } + } + }); + } + + protected function migrateConfigOptionProducts() + { + $this->info('Migrating Config option products...'); + + $this->migrateInBatch('configurable_options', 'SELECT c.id, c.name, c.type, c.order, c.hidden, c.group_id, g.name AS group_name, g.description AS group_description, g.products, c.created_at, c.updated_at FROM configurable_options as c JOIN configurable_option_groups as g ON c.group_id = g.id LIMIT :limit OFFSET :offset', function ($records) { + $config_option_products = []; + foreach ($records as $record) { + + $products = json_decode($record['products']); + + foreach ($products as $product_id) { + $config_option_products[] = [ + 'config_option_id' => $record['id'], + 'product_id' => (int) $product_id, + ]; + } + } + + DB::table('config_option_products')->insert($config_option_products); + }); + } + + protected function migrateExtensions() + { + $this->info('Migrating Extensions...'); + $stmt = $this->pdo->query('SELECT * FROM extensions'); + $records = $stmt->fetchAll(); + + $extensions = []; + foreach ($records as $record) { + try { + $extension = ExtensionHelper::getExtension($record['type'], $record['name']); + } catch (\Throwable $th) { + $ext_name = $record['name']; + $this->warn("Not Migrating '$ext_name', Error: " . $th->getMessage()); + + continue; + } + + $extensions[] = [ + 'id' => $record['id'], + 'name' => $record['display_name'] ?? $record['name'], + 'extension' => $record['name'], + 'type' => $record['type'], + 'enabled' => $record['enabled'], + ]; + + $stmt = $this->pdo->prepare('SELECT * FROM extension_settings WHERE `extension_id` = :id'); + $stmt->bindValue(':id', $record['id']); + $stmt->execute(); + $old_ext_settings = $stmt->fetchAll(PDO::FETCH_ASSOC); + $ext_name = $record['name']; + $ext_type = $record['type']; + + try { + $extension_cfg = ExtensionHelper::getConfig($ext_type, $ext_name); + } catch (\Throwable $th) { + $this->warn("Error while getting Extension '$ext_name', Not migrating ext settings: " . $th->getMessage()); + + continue; + } + + $extension_settings = []; + foreach ($old_ext_settings as $old_ext_setting) { + + // If a setting was renamed in v1, you can probably put the old and new one here + // the migrator may be able to move that setting + $old_ext_setting['key'] = match (strtolower($old_ext_setting['key'])) { + 'apikey' => 'api_key', + default => $old_ext_setting['key'], + }; + + $setting = array_filter($extension_cfg, fn ($ext) => $ext['name'] == $old_ext_setting['key']); + $setting = array_merge(...$setting); + + // Check if the extension wants the setting to be encrypted or not + if ($setting['encrypted'] ?? false) { + try { + // Check if the setting was already encrypted, if yes don't change it + Crypt::decryptString($old_ext_setting['value']); + } catch (\Throwable $th) { + // Else, encrypt it + $old_ext_setting['value'] = Crypt::encryptString($old_ext_setting['value']); + } + } else { + try { + $decrypted = Crypt::decryptString($old_ext_setting['value']); + // If the setting was encrypted, decrypted it + $old_ext_setting['value'] = $decrypted; + } catch (\Throwable $th) { + // Else, do nothing + } + } + + $extension_settings[] = [ + 'key' => $old_ext_setting['key'], + 'value' => $old_ext_setting['value'], + 'type' => $setting['database_type'] ?? 'string', + 'settingable_type' => 'App\Models\Server', + 'settingable_id' => $old_ext_setting['extension_id'], + 'encrypted' => $setting['encrypted'] ?? false, + 'created_at' => $old_ext_setting['created_at'], + 'updated_at' => $old_ext_setting['updated_at'], + ]; + } + + DB::table('settings')->insert($extension_settings); + } + + DB::table('extensions')->insert($extensions); + $this->info('Done.'); + } + + protected function migrateProducts() + { + $this->info('Migrating Products...'); + $this->migrateInBatch('products', 'SELECT * FROM products LIMIT :limit OFFSET :offset', function ($records) { + $records = array_map(function ($record) { + return [ + 'id' => $record['id'], + 'name' => $record['name'], + 'slug' => Str::slug($record['name']), + 'description' => $record['description'], + + 'category_id' => $record['category_id'], + 'image' => $record['image'], + 'stock' => $record['stock_enabled'] ? $record['stock'] : null, + 'per_user_limit' => $record['limit'], + 'allow_quantity' => match ($record['allow_quantity']) { + 0 => 'disabled', + 1 => 'separated', + 2 => 'combined', + default => 'disabled' + }, + 'server_id' => $record['extension_id'], + + 'created_at' => $record['created_at'], + 'updated_at' => $record['updated_at'], + ]; + }, $records); + + DB::table('products')->insert($records); + }); + + $this->info('Migrating Product Settings...'); + $this->migrateInBatch('product_settings', 'SELECT * FROM product_settings LIMIT :limit OFFSET :offset', function ($product_settings) { + + $records = []; + foreach ($product_settings as $record) { + try { + $extension = Server::findOrFail($record['extension']); + } catch (\Throwable $th) { + $extension = $record['extension']; + $this->warn("Error while getting Extension '$extension', Not migrating ext product settings: " . $th->getMessage()); + + continue; + } + + $migratedOption = ExtensionHelper::call($extension, 'migrateOption', [ + 'key' => $record['name'], + 'value' => $record['value'], + ], mayFail: true); + $records[] = [ + 'key' => $migratedOption['key'] ?? $record['name'], + 'value' => $migratedOption['value'] ?? $record['value'], + 'type' => $migratedOption['type'] ?? 'string', + 'settingable_type' => 'App\Models\Product', + 'settingable_id' => $record['product_id'], + 'encrypted' => $migratedOption['encrypted'] ?? false, + 'created_at' => $record['created_at'], + 'updated_at' => $record['updated_at'], + ]; + } + DB::table('settings')->insert($records); + }); + } + + protected function migrateProductUpgrades() + { + $this->info('Migrating Product Upgrades...'); + + $this->migrateInBatch( + 'product_upgrades', + 'SELECT * FROM `product_upgrades` LIMIT :limit OFFSET :offset', + function ($records) { + $records = array_map(function ($record) { + return [ + 'id' => $record['id'], + 'product_id' => $record['product_id'], + 'upgrade_id' => $record['upgrade_product_id'], + + 'created_at' => $record['created_at'], + 'updated_at' => $record['updated_at'], + ]; + }, $records); + + DB::table('product_upgrades')->insert($records); + } + ); + } + + protected function migrateServiceCancellations() + { + $this->info('Migrating Service Cancellations...'); + + $this->migrateInBatch( + 'cancellations', + 'SELECT cancellations.*, order_products.status as service_status + FROM `cancellations` + LEFT JOIN `order_products` ON cancellations.order_product_id = order_products.id LIMIT :limit OFFSET :offset', + function ($records) { + + $cancellations = []; + foreach ($records as $record) { + if ($record['service_status'] === 'cancelled') { + continue; + } + + $cancellations[] = [ + 'id' => $record['id'], + 'service_id' => $record['order_product_id'], + 'reason' => $record['reason'], + 'type' => 'end_of_period', + 'created_at' => $record['created_at'], + 'updated_at' => $record['updated_at'], + ]; + } + + DB::table('service_cancellations')->insert($cancellations); + } + ); + } + + protected function migrateInvoices() + { + $this->info('Migrating Invoices, Invoice Items, and Invoice Transactions...'); + + $this->migrateInBatch('invoices', 'SELECT * FROM invoices LIMIT :limit OFFSET :offset', function ($records) { + $invoice_ids = implode(',', array_column($records, 'id')); + $items_stmt = $this->pdo->prepare("SELECT + invoice_items.*, + order_products.id as service_id, + order_products.quantity as service_quantity + FROM + invoice_items + LEFT JOIN + order_products ON invoice_items.product_id = order_products.id + WHERE invoice_id IN($invoice_ids) + "); + $items_stmt->execute(); + $invoice_items_db = $items_stmt->fetchAll(PDO::FETCH_ASSOC); + + $invoice_transactions = []; + $invoice_items = []; + + $invoices = array_map(function ($record) use ($invoice_items_db, &$invoice_items, &$invoice_transactions) { + $transaction_amount = 0; + + $items = array_map(function ($item) use (&$transaction_amount) { + + $price = number_format((float) $item['total'], 2, '.', ''); + $transaction_amount += (float) $price; + + return [ + 'id' => $item['id'], + 'invoice_id' => $item['invoice_id'], + 'description' => $item['description'], + 'price' => number_format((float) $item['total'], 2, '.', ''), + 'quantity' => $item['service_quantity'] ?? 1, + + 'reference_type' => 'App\Models\Service', + 'reference_id' => $item['service_id'], + + 'created_at' => $item['created_at'], + 'updated_at' => $item['updated_at'], + ]; + }, array_filter($invoice_items_db, function ($item) use ($record) { + return $item['invoice_id'] === $record['id']; + })); + + // Add the transaction details to invoice_transactions + if ($transaction_amount > 0 && $record['status'] === 'paid') { + $gateway = Gateway::where('name', $record['paid_with'])->get()->first(); + $invoice_transactions[] = [ + 'invoice_id' => $record['id'], + 'transaction_id' => $record['paid_reference'], + 'gateway_id' => $gateway ? $gateway->id : null, + 'amount' => $transaction_amount, + 'fee' => null, + + 'created_at' => $record['created_at'], + 'updated_at' => $record['updated_at'], + ]; + } + + // Add the invoice items to invoice_items + $invoice_items = array_merge($invoice_items, $items); + + return [ + 'id' => $record['id'], + 'number' => $record['id'], + 'status' => $record['status'], + 'due_at' => $record['due_date'], + 'currency_code' => $this->currency_code, + 'user_id' => $record['user_id'], + + 'created_at' => $record['created_at'], + 'updated_at' => $record['updated_at'], + ]; + }, $records); + + DB::table('invoices')->insert($invoices); + DB::table('invoice_items')->insert($invoice_items); + DB::table('invoice_transactions')->insert($invoice_transactions); + + }); + // Update settings for invoice number + DB::table('settings')->updateOrInsert( + ['key' => 'invoice_number'], + ['value' => DB::table('invoices')->max('id') ?: 0] + ); + } + + protected function migratePlans() + { + $this->info('Migrating Plans and Prices...'); + + $stmt = $this->pdo->query('SELECT * FROM product_price'); + $records = $stmt->fetchAll(); + + $plans = []; + + foreach ($records as $record) { + + $common_fields = [ + 'type' => $record['type'], + 'priceable_id' => $record['product_id'], + 'priceable_type' => 'App\Models\Product', + ]; + + if ($record['monthly']) { + array_push($plans, array_merge([ + 'name' => 'Monthly', + 'billing_period' => 1, + 'billing_unit' => 'month', + 'price' => [ + 'price' => $record['monthly'], + 'setup_fee' => $record['monthly_setup'], + 'currency_code' => $this->currency_code, + ], + ], $common_fields)); + } + + if ($record['quarterly']) { + array_push($plans, array_merge([ + 'name' => 'Quarterly', + 'billing_period' => 3, + 'billing_unit' => 'month', + 'price' => [ + 'price' => $record['quarterly'], + 'setup_fee' => $record['quarterly_setup'], + 'currency_code' => $this->currency_code, + ], + ], $common_fields)); + } + + if ($record['semi_annually']) { + array_push($plans, array_merge([ + 'name' => 'Semi-Annually', + 'billing_period' => 6, + 'billing_unit' => 'month', + 'price' => [ + 'price' => $record['semi_annually'], + 'setup_fee' => $record['semi_annually_setup'], + 'currency_code' => $this->currency_code, + ], + ], $common_fields)); + } + + if ($record['annually']) { + array_push($plans, array_merge([ + 'name' => 'Annually', + 'billing_period' => 1, + 'billing_unit' => 'year', + 'price' => [ + 'price' => $record['annually'], + 'setup_fee' => $record['annually_setup'], + 'currency_code' => $this->currency_code, + ], + ], $common_fields)); + } + + if ($record['biennially']) { + array_push($plans, array_merge([ + 'name' => 'Biennially', + 'billing_period' => 2, + 'billing_unit' => 'year', + 'price' => [ + 'price' => $record['biennially'], + 'setup_fee' => $record['biennially_setup'], + 'currency_code' => $this->currency_code, + ], + ], $common_fields)); + } + + if ($record['triennially']) { + array_push($plans, array_merge([ + 'name' => 'Triennially', + 'billing_period' => 3, + 'billing_unit' => 'year', + 'price' => [ + 'price' => $record['triennially'], + 'setup_fee' => $record['triennially_setup'], + 'currency_code' => $this->currency_code, + ], + ], $common_fields)); + } + } + + $all_prices = []; + foreach ($plans as $plan) { + $price = $plan['price']; + // Unset the price from the plan array, so it can be inserted without errors + unset($plan['price']); + $plan_id = DB::table('plans')->insertGetId($plan); + $all_prices[] = array_merge([ + 'plan_id' => $plan_id, + ], $price); + } + DB::table('prices')->insert($all_prices); + + $this->info('Done.'); + } + + protected function migrateOrdersAndServices() + { + $this->info('Migrating Orders...'); + $order_product_details = []; + + $this->migrateInBatch('orders', 'SELECT * FROM orders LIMIT :limit OFFSET :offset', function ($records) use (&$order_product_details) { + $records = array_map(function ($record) use (&$order_product_details) { + $order_product_details[$record['id']] = [ + 'coupon_id' => $record['coupon_id'], + 'user_id' => $record['user_id'], + ]; + + return [ + 'id' => $record['id'], + 'user_id' => $record['user_id'], + 'currency_code' => $this->currency_code, + + 'created_at' => $record['created_at'], + 'updated_at' => $record['updated_at'], + ]; + }, $records); + + DB::table('orders')->insert($records); + }); + + $this->info('Migrating Services...'); + $this->migrateInBatch('order_products', 'SELECT + op.*, + opc.value as stripe_subscription_id + FROM + order_products op + LEFT JOIN + order_products_config opc + ON op.id = opc.order_product_id + AND opc.key = \'stripe_subscription_id\' + LIMIT :limit OFFSET :offset + ', function ($records) use ($order_product_details) { + $records = array_map(function ($record) use ($order_product_details) { + $order = $order_product_details[$record['order_id']]; + + $billing = match ($record['billing_cycle']) { + 'monthly' => [ + 'type' => 'recurring', + 'unit' => 'month', + 'period' => 1, + ], + 'quarterly' => [ + 'type' => 'recurring', + 'unit' => 'month', + 'period' => 3, + ], + 'semi_annually' => [ + 'type' => 'recurring', + 'unit' => 'month', + 'period' => 6, + ], + 'annually' => [ + 'type' => 'recurring', + 'unit' => 'year', + 'period' => 1, + ], + 'biennially' => [ + 'type' => 'recurring', + 'unit' => 'year', + 'period' => 2, + ], + 'triennially' => [ + 'type' => 'recurring', + 'unit' => 'year', + 'period' => 3, + ], + null => $record['price'] === 0 ? [ + 'type' => 'free', + 'unit' => null, + 'period' => null, + ] : [ + 'type' => 'one-time', + 'unit' => null, + 'period' => null, + ] + }; + + $price = Price::where('price', $record['price']) + ->whereHas('plan', function ($query) use ($billing) { + $query->where('priceable_type', 'App\Models\Product') + ->where('type', $billing['type']) + ->where('billing_period', $billing['period']) + ->where('billing_unit', $billing['unit']); + })->first(); + + if (!$price) { + // Select the plan where the price doesn't match + $plan = DB::table('plans') + ->where('priceable_type', 'App\Models\Product') + ->where('type', $billing['type']) + ->where('billing_period', $billing['period']) + ->where('billing_unit', $billing['unit']) + ->first(); + if ($plan) { + $plan_id = $plan->id; + } else { + // If the plan doesn't exist, create it + $this->warn("Price not found for order_product_id: {$record['id']}, Creating custom plan."); + $plan_id = DB::table('plans')->insertGetId([ + 'name' => "Custom - {$billing['unit']} {$billing['type']}", + 'type' => $billing['type'], + 'billing_period' => $billing['period'], + 'billing_unit' => $billing['unit'], + 'priceable_id' => $record['product_id'], + 'priceable_type' => 'App\Models\Product', + ]); + + DB::table('prices')->insert([ + 'plan_id' => $plan_id, + 'price' => $record['price'], + 'setup_fee' => null, + 'currency_code' => $this->currency_code, + ]); + } + } + + return [ + 'id' => $record['id'], + // Active instead of Paid status, leave rest unchanged + 'status' => match ($record['status']) { + 'paid' => 'active', + null => 'cancelled', + default => $record['status'] + }, + 'order_id' => $record['order_id'], + 'product_id' => $record['product_id'], + 'user_id' => $order['user_id'], + 'currency_code' => $this->currency_code, + + 'quantity' => $record['quantity'], + 'price' => $record['price'], + + 'plan_id' => $price->plan_id ?? $plan_id, + 'coupon_id' => $order['coupon_id'], + 'expires_at' => $record['expiry_date'], + 'subscription_id' => $record['stripe_subscription_id'], + 'created_at' => $record['created_at'], + 'updated_at' => $record['updated_at'], + ]; + }, $records); + DB::table('services')->insert($records); + }); + } + + protected function migrateServiceConfigs() + { + $this->info('Migrating Service Configs...'); + $this->migrateInBatch('order_products_config', 'SELECT * FROM order_products_config LIMIT :limit OFFSET :offset', function ($records) { + $service_properties = []; + $service_configs = []; + foreach ($records as $record) { + if ($record['key'] === 'stripe_subscription_id') { + continue; + } + if ($record['is_configurable_option'] === 1) { + $configOption = ConfigOption::whereId($record['key'])->first(); + if (!$configOption) { + $this->warn("Config option not found for order_product_id: {$record['order_product_id']}, key: {$record['key']}"); + + continue; + } + if (in_array($configOption->type, ['text', 'number'])) { + $service_properties[] = [ + 'name' => $record['key'], + 'key' => $record['key'], + 'custom_property_id' => null, + 'model_id' => $record['order_product_id'], + 'model_type' => 'App\Models\Service', + 'value' => $record['value'], + ]; + + continue; + } + $service_configs[] = [ + 'configurable_type' => 'App\Models\Service', + 'configurable_id' => $record['order_product_id'], + 'config_option_id' => $configOption->id, + 'config_value_id' => $record['value'], + ]; + } else { + $service_properties[] = [ + 'name' => $record['key'], + 'key' => $record['key'], + 'custom_property_id' => null, + 'model_id' => $record['order_product_id'], + 'model_type' => 'App\Models\Service', + 'value' => $record['value'], + ]; + } + } + + DB::table('service_configs')->insert($service_configs); + DB::table('properties')->insert($service_properties); + }); + } + + protected function migrateUsers() + { + $this->info('Migrating Users...'); + + // Custom Properties for users + $address = CustomProperty::where('model', 'App\Models\User')->where('key', 'address')->first(); + $city = CustomProperty::where('model', 'App\Models\User')->where('key', 'city')->first(); + $state = CustomProperty::where('model', 'App\Models\User')->where('key', 'state')->first(); + $zip = CustomProperty::where('model', 'App\Models\User')->where('key', 'zip')->first(); + $country = CustomProperty::where('model', 'App\Models\User')->where('key', 'country')->first(); + $phone = CustomProperty::where('model', 'App\Models\User')->where('key', 'phone')->first(); + $companyname = CustomProperty::where('model', 'App\Models\User')->where('key', 'company_name')->first(); + + $this->migrateInBatch('users', 'SELECT * FROM users LIMIT :limit OFFSET :offset', function ($records) use ( + $address, + $city, + $state, + $zip, + $country, + $phone, + $companyname + ) { + $properties = []; + $credits = []; + + $records = array_map(function ($record) use ( + &$properties, + &$credits, + $address, + $city, + $state, + $zip, + $country, + $phone, + $companyname + ) { + // User properties + if ($record['address']) { + array_push($properties, [ + 'name' => $address->name, + 'key' => $address->key, + 'custom_property_id' => $address->id, + 'model_id' => $record['id'], + 'model_type' => 'App\Models\User', + 'value' => $record['address'], + ]); + } + if ($record['city']) { + array_push($properties, [ + 'name' => $city->name, + 'key' => $city->key, + 'custom_property_id' => $city->id, + 'model_id' => $record['id'], + 'model_type' => 'App\Models\User', + 'value' => $record['city'], + ]); + } + if ($record['state']) { + array_push($properties, [ + 'name' => $state->name, + 'key' => $state->key, + 'custom_property_id' => $state->id, + 'model_id' => $record['id'], + 'model_type' => 'App\Models\User', + 'value' => $record['state'], + ]); + } + if ($record['zip']) { + array_push($properties, [ + 'name' => $zip->name, + 'key' => $zip->key, + 'custom_property_id' => $zip->id, + 'model_id' => $record['id'], + 'model_type' => 'App\Models\User', + 'value' => $record['zip'], + ]); + } + if ($record['country']) { + array_push($properties, [ + 'name' => $country->name, + 'key' => $country->key, + 'custom_property_id' => $country->id, + 'model_id' => $record['id'], + 'model_type' => 'App\Models\User', + 'value' => $record['country'], + ]); + } + if ($record['phone']) { + array_push($properties, [ + 'name' => $phone->name, + 'key' => $phone->key, + 'custom_property_id' => $phone->id, + 'model_id' => $record['id'], + 'model_type' => 'App\Models\User', + 'value' => $record['phone'], + ]); + } + if ($record['companyname']) { + array_push($properties, [ + 'name' => $companyname->name, + 'key' => $companyname->key, + 'custom_property_id' => $companyname->id, + 'model_id' => $record['id'], + 'model_type' => 'App\Models\User', + 'value' => $record['companyname'], + ]); + } + if ($record['credits']) { + array_push($credits, [ + 'user_id' => $record['id'], + 'amount' => $record['credits'], + 'currency_code' => $this->currency_code, + 'created_at' => $record['created_at'], + 'updated_at' => $record['updated_at'], + ]); + } + + // User Details + return [ + 'id' => $record['id'], + 'first_name' => $record['first_name'], + 'last_name' => $record['last_name'], + 'email' => $record['email'], + // If the user had admin role, then give him admin, otherwise give no role + 'role_id' => $record['role_id'] === 1 ? 1 : null, + 'email_verified_at' => $record['email_verified_at'], + 'password' => $record['password'], + 'tfa_secret' => $record['tfa_secret'] ? Crypt::encryptString(Crypt::decrypt($record['tfa_secret'])) : null, + 'created_at' => $record['created_at'], + 'updated_at' => $record['updated_at'], + 'remember_token' => $record['remember_token'], + ]; + }, $records); + + DB::table('users')->insert($records); + DB::table('properties')->insert($properties); + DB::table('credits')->insert($credits); + }); + } + + protected function migrateTickets() + { + $this->info('Migrating Tickets...'); + $this->migrateInBatch('tickets', 'SELECT * FROM tickets LIMIT :limit OFFSET :offset', function ($records) { + $records = array_map(function ($record) { + return [ + 'id' => $record['id'], + 'subject' => $record['title'], + 'status' => $record['status'], + 'priority' => $record['priority'], + 'department' => null, + + 'assigned_to' => $record['assigned_to'], + 'user_id' => $record['user_id'], + 'service_id' => $record['order_id'], + + 'created_at' => $record['created_at'], + 'updated_at' => $record['updated_at'], + ]; + }, $records); + + DB::table('tickets')->insert($records); + }); + } + + protected function migrateTicketMessages() + { + $this->info('Migrating Ticket Messages...'); + $this->migrateInBatch('ticket_messages', 'SELECT * FROM ticket_messages LIMIT :limit OFFSET :offset', function ($records) { + + $records = array_filter($records, fn ($record) => !is_null($record['message']) && $record['message'] !== ''); + + $records = array_map(function ($record) { + return [ + 'id' => $record['id'], + 'ticket_id' => $record['ticket_id'], + 'user_id' => $record['user_id'], + 'message' => $record['message'], + + 'created_at' => $record['created_at'], + 'updated_at' => $record['updated_at'], + ]; + }, $records); + + DB::table('ticket_messages')->insert($records); + }); + } + + protected function migrateTaxRates() + { + $this->info('Migrating Tax Rates...'); + $this->migrateInBatch('tax_rates', 'SELECT * FROM tax_rates LIMIT :limit OFFSET :offset', function ($records) { + $records = array_map(function ($record) { + return [ + 'id' => $record['id'], + 'name' => $record['name'], + 'rate' => $record['rate'], + 'country' => $record['country'], + + 'created_at' => $record['created_at'], + 'updated_at' => $record['updated_at'], + ]; + }, $records); + + // Country should be unique + $records = array_filter($records, function ($record) { + static $countries = []; + if (in_array($record['country'], $countries)) { + $this->error("Duplicate country found: {$record['country']}," . + " Tax Rate with ID: {$record['id']} will not be migrated."); + + return false; + } + $countries[] = $record['country']; + + return true; + }); + + DB::table('tax_rates')->insert($records); + }); + } + + protected function migrateCategories() + { + $this->info('Migrating Categories...'); + $this->migrateInBatch('categories', 'SELECT * FROM categories LIMIT :limit OFFSET :offset', function ($records) { + $records = array_map(function ($record) { + return [ + + 'id' => $record['id'], + 'slug' => $record['slug'], + 'name' => $record['name'], + 'description' => $record['description'], + 'image' => $record['image'], + 'parent_id' => $record['category_id'], + 'full_slug' => $record['slug'], + + 'created_at' => $record['created_at'], + 'updated_at' => $record['updated_at'], + ]; + }, $records); + + DB::table('categories')->insert($records); + }); + } + + protected function migrateCoupons() + { + $this->info('Migrating Coupons...'); + $this->migrateInBatch('coupons', 'SELECT * FROM coupons LIMIT :limit OFFSET :offset', function ($records) { + $coupon_products = []; + + $records = array_map(function ($record) { + if ($record['products']) { + foreach (json_decode($record['products']) as $product_id) { + $coupon_products[] = [ + 'coupon_id' => (int) $record['id'], + 'product_id' => (int) $product_id, + ]; + } + } + + return [ + 'id' => $record['id'], + + 'type' => $record['type'] == 'percent' ? 'percentage' : 'fixed', + 'recurring' => null, + 'code' => $record['code'], + 'value' => number_format((float) $record['value'], 2, '.', ''), + 'max_uses' => (int) $record['max_uses'], + 'starts_at' => $record['start_date'], + 'expires_at' => $record['end_date'], + + 'created_at' => $record['created_at'], + 'updated_at' => $record['updated_at'], + ]; + }, $records); + + DB::table('coupons')->insert($records); + DB::table('coupon_products')->insert($coupon_products); + }); + } +} diff --git a/app/Console/Commands/Settings/Change.php b/app/Console/Commands/Settings/Change.php new file mode 100644 index 000000000..323cd9019 --- /dev/null +++ b/app/Console/Commands/Settings/Change.php @@ -0,0 +1,64 @@ +argument('key'); + $value = $this->argument('value'); + $form = form(); + + if (!$key) { + // Settings::settings is a array with first level keys as categories and second level keys as settings, so we need to flatten it + $settings = collect(Settings::settings())->flatten(1)->map(function ($item) { + return $item['name']; + })->toArray(); + + $form->suggest('Which setting would you like to change?', $settings, name: 'key'); + } + + if (!$value) { + $form->add(function ($responses) use ($key) { + $key = $responses['key'] ?? $key; + $setting = Settings::getSetting($key); + if (!isset($setting->type)) { + return text('What value should the setting have?', default: '', hint: 'Could not find setting but you can still change it'); + } + // What type is the setting? + if ($setting->type === 'select') { + return select('What value should the setting have?', $setting->options); + } else { + return text('What value should the setting have?', default: $setting->default ?? ''); + } + }); + } + $form = $form->submit(); + + if (isset($form['key'])) { + $key = $form['key']; + } + if (isset($form[0]) || isset($form[1])) { + $value = $form[0] ?? $form[1]; + } + + Setting::updateOrCreate( + ['key' => $key], + ['value' => $value] + ); + } +} diff --git a/app/Console/Commands/TelemetryCommand.php b/app/Console/Commands/TelemetryCommand.php new file mode 100644 index 000000000..9dacdfef6 --- /dev/null +++ b/app/Console/Commands/TelemetryCommand.php @@ -0,0 +1,92 @@ +info('Gathering telemetry data...'); + + $data = [ + 'uuid' => Settings::getTelemetry()['uuid'], + 'version' => config('app.version'), + 'php_version' => phpversion(), + 'drivers' => [ + 'cache' => [ + 'type' => config('cache.default'), + ], + + 'database' => [ + 'type' => config('database.default'), + 'version' => DB::getPdo()->getAttribute(\PDO::ATTR_SERVER_VERSION), + ], + ], + 'database_counts' => [ + 'invoices' => [ + 'count' => DB::table('invoices')->count(), + 'paid' => DB::table('invoices')->where('status', 'paid')->count(), + ], + 'services' => [ + 'count' => DB::table('services')->count(), + 'active' => DB::table('services')->where('status', 'active')->count(), + ], + 'products' => [ + 'count' => DB::table('products')->count(), + ], + 'currencies' => [ + 'count' => DB::table('currencies')->count(), + 'currencies' => DB::table('currencies')->pluck('code')->toArray(), + ], + 'users' => [ + 'count' => DB::table('users')->count(), + 'admins' => DB::table('users')->where('role_id', '!=', null)->count(), + ], + 'extensions' => [ + 'count' => DB::table('extensions')->count(), + 'active' => DB::table('extensions')->where('enabled', true)->pluck('extension')->toArray(), + ], + ], + ]; + + if ($this->option('simulate')) { + $this->info('Simulating telemetry data...'); + $this->line(json_encode($data, JSON_PRETTY_PRINT)); + + return; + } + + // Send telemetry data + $this->info('Sending telemetry data...'); + $response = Http::post('https://api.paymenter.org/statistics', $data)->throw(); + + if ($response->successful()) { + $this->info('Telemetry data sent successfully.'); + } else { + $this->error('Failed to send telemetry data: ' . $response->body()); + } + } +} diff --git a/app/Console/Commands/Upgrade.php b/app/Console/Commands/Upgrade.php new file mode 100644 index 000000000..492996333 --- /dev/null +++ b/app/Console/Commands/Upgrade.php @@ -0,0 +1,104 @@ +info('Starting upgrade process...'); + + if (version_compare(PHP_VERSION, '8.2.0') < 0) { + $this->error('Cannot execute self-upgrade process. The minimum required PHP version required is 8.1, you have [' . PHP_VERSION . '].'); + } + + $user = 'www-data'; + $group = 'www-data'; + if ($this->input->isInteractive()) { + if (is_null($this->option('user'))) { + $userDetails = posix_getpwuid(fileowner('public')); + $user = $userDetails['name'] ?? 'www-data'; + + if (!$this->confirm("Your webserver user has been detected as [{$user}]: is this correct?", true)) { + $user = $this->anticipate( + 'Please enter the name of the user running your webserver process. This varies from system to system, but is generally "www-data", "nginx", or "apache".', + [ + 'www-data', + 'nginx', + 'apache', + ] + ); + } + } + + if (is_null($this->option('group'))) { + $groupDetails = posix_getgrgid(filegroup('public')); + $group = $groupDetails['name'] ?? 'www-data'; + + if (!$this->confirm("Your webserver group has been detected as [{$group}]: is this correct?", true)) { + $group = $this->anticipate( + 'Please enter the name of the group running your webserver process. Normally this is the same as your user.', + [ + 'www-data', + 'nginx', + 'apache', + ] + ); + } + } + + if (!$this->confirm('Are you sure you want to run the upgrade process for your Panel?')) { + $this->warn('Upgrade process terminated by user.'); + + return; + } + } + ini_set('output_buffering', '0'); + // Call update.sh + $this->line('$upgrader> curl -L "https://raw.githubusercontent.com/paymenter/paymenter/master/update.sh" | bash -s -- --user=' . $user . ' --group=' . $group . ' --url=' . $this->getUrl()); + $process = Process::fromShellCommandline('curl -L "https://raw.githubusercontent.com/paymenter/paymenter/master/update.sh" | bash -s -- --user=' . $user . ' --group=' . $group . ' --url=' . $this->getUrl(), null, null, null, 1200); + $process->run(function ($type, $buffer) { + $this->{$type === Process::ERR ? 'error' : 'line'}($buffer); + }); + + $this->info('Upgrade process completed successfully!'); + + return Command::SUCCESS; + } + + protected function getUrl(): string + { + if ($this->option('url')) { + return $this->option('url'); + } + + return sprintf(self::DEFAULT_URL, 'latest/download'); + } +} diff --git a/app/Console/Commands/User/Create.php b/app/Console/Commands/User/Create.php new file mode 100644 index 000000000..2ff1ea1af --- /dev/null +++ b/app/Console/Commands/User/Create.php @@ -0,0 +1,60 @@ + $this->argument('first_name'), + 'last_name' => $this->argument('last_name'), + 'email' => $this->argument('email'), + 'password' => $this->argument('password'), + 'role_id' => $this->argument('role') ?? null, + ]); + } + + protected function promptForMissingArgumentsUsing(): array + { + $roleOptions = Role::all()->pluck('name', 'id')->toArray(); + $roleOptions[0] = 'None'; + + return [ + 'first_name' => 'What is the user\'s first name?', + 'last_name' => 'What is the user\'s last name?', + 'email' => 'What is the user\'s email address?', + 'password' => fn () => password('What is the user\'s password?', required: true), + 'role' => fn () => select( + label: 'What is the user\'s role?', + options: $roleOptions, + default: 0, + ), + ]; + } +} diff --git a/app/Console/Commands/User/PasswordReset.php b/app/Console/Commands/User/PasswordReset.php new file mode 100644 index 000000000..9d9995e47 --- /dev/null +++ b/app/Console/Commands/User/PasswordReset.php @@ -0,0 +1,68 @@ +argument('email'); + + if (!filter_var($email, FILTER_VALIDATE_EMAIL)) { + $this->error('Invalid email format'); + + return; + } + + $user = User::where('email', $email)->first(); + + if (!$user) { + $this->error("User with email '{$email}' not found"); + + return; + } + + try { + NotificationHelper::passwordResetNotification($user, [ + 'url' => url(route('password.reset', [ + 'token' => Password::createToken($user), + 'email' => $user->email, + ], false)), + ]); + + $this->info("Password reset email sent successfully to '{$email}'"); + } catch (\Throwable $e) { + $this->error('Failed to send password reset email: ' . $e->getMessage()); + } + } + + protected function promptForMissingArgumentsUsing(): array + { + return [ + 'email' => 'What is the user\'s email address?', + ]; + } +} diff --git a/app/Events/Auth/Login.php b/app/Events/Auth/Login.php new file mode 100644 index 000000000..3303c3224 --- /dev/null +++ b/app/Events/Auth/Login.php @@ -0,0 +1,21 @@ +encrypted && $setting->value) { + try { + $setting->value = Crypt::decryptString($setting->value); + } catch (\Throwable $th) { + // Normal `throw new Exception($th)` wasn't working here, so we are using dump-and-die for now. + dd($th, $setting->value); + } + } + + switch ($setting->type) { + case 'boolean': + $setting->value = (bool) $setting->value; + case 'integer': + $setting->value = (int) $setting->value; + case 'float': + $setting->value = (float) $setting->value; + case 'array': + $setting->value = json_decode($setting->value, true); + default: + return; + } + } +} diff --git a/app/Events/Setting/Saved.php b/app/Events/Setting/Saved.php new file mode 100644 index 000000000..77e4cb53a --- /dev/null +++ b/app/Events/Setting/Saved.php @@ -0,0 +1,34 @@ +settingable_type === null) { + $cSetting = \App\Classes\Settings::getSetting($setting->key); + // Set the config value for the setting + $settings = config('settings', []); + $settings[$setting->key] = $setting->value; + Config::set('settings', $settings); + // Does it have overrides? + if (isset($cSetting->override) && config("settings.$cSetting->name") !== null) { + Config::set($cSetting->override, config("settings.$cSetting->name")); + } + } + } +} diff --git a/app/Events/Setting/Saving.php b/app/Events/Setting/Saving.php new file mode 100644 index 000000000..10f8cc4a8 --- /dev/null +++ b/app/Events/Setting/Saving.php @@ -0,0 +1,48 @@ +encrypted) { + try { + $setting->value = Crypt::encryptString($setting->value); + } catch (\Throwable $th) { + // Normal `throw new Exception($th)` wasn't working here, so we are using dump-and-die for now. + dd($th, $setting->value); + } + + // An encrypted value can only be a string, so we refrain from converting its type + return $setting; + } + + switch ($setting->type) { + case 'boolean': + $setting->value = (bool) $setting->value; + case 'integer': + $setting->value = (int) $setting->value; + case 'float': + $setting->value = (float) $setting->value; + case 'array': + if (!is_string($setting->value) || is_null(json_decode($setting->value))) { + $setting->value = json_encode($setting->value); + } + break; + default: + return $setting; + } + } +} diff --git a/app/Events/Ticket/Created.php b/app/Events/Ticket/Created.php new file mode 100644 index 000000000..b534f3dd9 --- /dev/null +++ b/app/Events/Ticket/Created.php @@ -0,0 +1,18 @@ +isInstanceOf(ApiController::class); + } + + public function getMethodReturnType(MethodCallEvent $event): ?Type + { + if ($event->name === 'allowedIncludes') { + return $event->getArg('includes', 0); + } + + return null; + } +} diff --git a/app/Helpers/ApiDocumentation/RequestUserExtension.php b/app/Helpers/ApiDocumentation/RequestUserExtension.php new file mode 100644 index 000000000..bb79a072c --- /dev/null +++ b/app/Helpers/ApiDocumentation/RequestUserExtension.php @@ -0,0 +1,27 @@ +isInstanceOf(Request::class); + } + + public function getMethodReturnType(MethodCallEvent $event): ?Type + { + if ($event->name === 'user') { + return new ObjectType(User::class); + } + + return null; + } +} diff --git a/app/Helpers/EventHelper.php b/app/Helpers/EventHelper.php new file mode 100644 index 000000000..f2460a475 --- /dev/null +++ b/app/Helpers/EventHelper.php @@ -0,0 +1,65 @@ + ($b['priority'] ?? 0); + }); + + return $items; + } + + public static function renderEvent($event) + { + $eventItems = Event::dispatch($event); + // Make multidimensional array flat + $eventItems = array_reduce($eventItems, function ($carry, $item) { + // Is item a multidimensional array? + if (is_array($item) && isset($item[0])) { + return array_merge($carry, $item); + } + + return array_merge($carry, [$item]); + }, []); + + // Sort based on priority + usort($eventItems, function ($a, $b) { + return ($a['priority'] ?? 0) <=> ($b['priority'] ?? 0); + }); + + $view = ''; + foreach ($eventItems as $item) { + if (isset($item['view'])) { + $view .= $item['view']; + } + } + + // Now we smash them together and return it as html + return $view; + } +} diff --git a/app/Helpers/ExtensionHelper.php b/app/Helpers/ExtensionHelper.php new file mode 100644 index 000000000..2427a2ba9 --- /dev/null +++ b/app/Helpers/ExtensionHelper.php @@ -0,0 +1,510 @@ + !in_array($extension['type'], ['gateway', 'server'])); + + return $extensions; + } elseif ($type) { + $type = strtolower($type); + + return array_filter($extensions, fn ($extension) => $extension['type'] === $type); + } + + return $extensions; + } + + /** + * Get extension and return new instance + * + * @param string $type + * @param string $extension + * @return object + */ + public static function getExtension($type, $extension, $config = []) + { + $extension = '\\Paymenter\\Extensions\\' . ucfirst($type) . 's\\' . $extension . '\\' . $extension; + + if (!class_exists($extension)) { + throw new Exception('Extension "' . $extension . '" not found'); + } + + if (!is_array($config)) { + $config = self::settingsToArray($config); + } + + return new $extension($config); + } + + /** + * Get available settings + * + * @return array + */ + public static function getConfig($type, $extension, $config = []) + { + if (empty($config)) { + $typeClass = ($type == 'gateway') ? Gateway::class : (($type == 'server') ? Server::class : Extension::class); + $config = $typeClass::where('extension', $extension)->exists() + ? $typeClass::where('extension', $extension)->first()->settings->pluck('value', 'key')->toArray() + : []; + } + + return self::getExtension($type, $extension)->getConfig($config); + } + + /** + * Has function + * + * @param object $extension + * @param string $function + */ + public static function hasFunction($extension, $function) + { + return method_exists(self::getExtension($extension->type, $extension->extension, $extension->settings), $function); + } + + /** + * Test connection + * + * @return string + */ + public static function testConfig($extension, $values) + { + return self::getExtension($extension->type, $extension->extension, $values)->testConfig(); + } + + /** + * Get checkout configuration + * + * @return array + */ + public static function getCheckoutConfig(Product $product, $values = []) + { + $server = $product->server; + if (!$server) { + return []; + } + + return self::call($server, 'getCheckoutConfig', [$product, $values, self::settingsToArray($product->settings)], mayFail: true) ?? []; + } + + /** + * Get all extensions which are not gateways or servers with their settings + * + * @return array + */ + public static function getAvailableExtensions() + { + $extensions = []; + + $classmap = require_once base_path('vendor/composer/autoload_classmap.php'); + + // Magic code so we can also support extensions that don't reside in the extensions folder + foreach ($classmap as $class => $path) { + if (strpos($class, 'Paymenter\\Extensions\\') !== 0) { + continue; + } + + // Example: Paymenter\Extensions\Whatevers\SomeExtension\SomeExtension + $parts = explode('\\', $class); + + // Must have at least: Paymenter, Extensions, s, , + if (count($parts) < 5) { + continue; + } + + $typePlural = $parts[2]; + + $type = strtolower(rtrim($typePlural, 's')); + $name = $parts[3]; + + // Only add the main extension class (class name matches extension folder) + if ($parts[4] !== $name) { + continue; + } + + $extensions[] = [ + 'name' => $name, + 'type' => $type, + ]; + } + + // Newly created extensions sometimes don't have a classmap entry, so we also check the filesystem + $extensionPath = base_path('extensions'); + $typeFolders = glob($extensionPath . '/*', GLOB_ONLYDIR); + foreach ($typeFolders as $typeFolder) { + $type = strtolower(rtrim(basename($typeFolder), 's')); + $extensionDirs = glob($typeFolder . '/*', GLOB_ONLYDIR); + + foreach ($extensionDirs as $extensionDir) { + $name = basename($extensionDir); + + // CHeck if already added + if (in_array($name, array_column($extensions, 'name')) && in_array($type, array_column($extensions, 'type'))) { + continue; + } + + // Check if the class exists + if (class_exists('\\Paymenter\\Extensions\\' . ucfirst($type) . 's\\' . $name . '\\' . $name)) { + $extensions[] = [ + 'name' => $name, + 'type' => $type, + ]; + } + } + } + + return $extensions; + } + + public static function call($extension, $function, $args = [], $mayFail = false) + { + try { + if (!self::hasFunction($extension, $function)) { + throw new Exception('Function not found'); + } + + return self::getExtension($extension->type, $extension->extension, $extension->settings)->$function(...$args); + } catch (Exception $e) { + if (!$mayFail) { + throw $e; + } + } + } + + public static function callService(Service $service, $function, $args = [], $mayFail = false) + { + $server = $service->product->server; + + if (!$server) { + if ($mayFail) { + throw new Exception('No server assigned to this product'); + } else { + return; + } + } + + return self::call($server, $function, [$service, self::settingsToArray($service->product->settings), self::getServiceProperties($service), ...$args], $mayFail); + } + + /** + * Convert extensions to options + * + * @param Extension $extension + * @return object + */ + public static function getConfigAsInputs(string $type, ?string $name, $config = []) + { + if (!$name) { + return []; + } + + $settings = []; + + try { + foreach (self::getConfig($type, $name, $config) as $key => $config) { + $config['name'] = 'settings.' . $config['name']; + $settings[] = FilamentInput::convert($config); + } + } catch (\Exception $e) { + $settings[] = Placeholder::make('error')->content($e->getMessage()); + // Handle exception + } + + return $settings; + } + + /** + * Get available settings + * + * @return array + */ + public static function getProductConfig($server, $values = []) + { + return self::call($server, 'getProductConfig', [$values]); + } + + protected static function prepareForSerialization($values) + { + if (is_array($values)) { + foreach ($values as $key => $value) { + $values[$key] = self::prepareForSerialization($value); + } + + return $values; + } + + if ($values instanceof \Livewire\Features\SupportFileUploads\TemporaryUploadedFile) { + // Store the file and use the path, or just use the filename if already stored + return $values->getRealPath() ?: (string) $values; + } + + return $values; + } + + /** + * Get available settings + * + * @return array + */ + public static function getProductConfigOnce($server, $values = []) + { + static $config = []; + + $config = Cache::get('product_config', []); + + $key = $server->extension . $server->id . md5(serialize(self::prepareForSerialization($values))); + + if (!isset($config[$key])) { + $config[$key] = self::getProductConfig($server, $values); + } + + Cache::put('product_config', $config, 60); + + return $config[$key]; + } + + /** + * Convert settings to array + * + * @param mixed $settings + * @return array + */ + public static function settingsToArray($settings) + { + $settingsArray = []; + + if ($settings instanceof \Illuminate\Database\Eloquent\Collection) { + // If $settings is a collection of models + foreach ($settings as $setting) { + $settingsArray[$setting->key] = $setting->value; + } + } elseif ($settings instanceof \Illuminate\Database\Eloquent\Model) { + // If $settings is a single model + $settingsArray[$settings->name] = $settings->value; + } + + return $settingsArray ?? $settings; + } + + /** + * Register a new middleware. + * + * @param string $middleware + * @param string $group + * @return Router + */ + public static function registerMiddleware($middleware, $group = 'web') + { + return app('router')->pushMiddlewareToGroup($group, $middleware); + } + + /** + * Get every gateway which allows to checkout with + * + * @return array + */ + public static function getCheckoutGateways($items, $type) + { + $gateways = []; + + foreach (Gateway::all() as $gateway) { + if (self::hasFunction($gateway, 'canUseGateway')) { + if (self::getExtension('gateway', $gateway->extension, $gateway->settings)->canUseGateway($items, $type)) { + $gateways[] = $gateway; + } + } else { + $gateways[] = $gateway; + } + } + + return $gateways; + } + + /** + * Get payment url or view + */ + public static function pay($gateway, $invoice) + { + return self::getExtension('gateway', $gateway->extension, $gateway->settings)->pay($invoice, $invoice->remaining); + } + + /** + * Add payment to invoice + */ + public static function addPayment($invoice, $gateway, $amount, $fee = null, $transactionId = null) + { + if (isset($gateway)) { + $gateway = Gateway::where('extension', $gateway)->first(); + } + + $invoice = Invoice::findOrFail($invoice); + if (!$transactionId) { + $invoice->transactions()->create([ + 'gateway_id' => $gateway ? $gateway->id : null, + 'amount' => $amount, + 'fee' => $fee, + ]); + } else { + $invoice->transactions()->updateOrCreate( + [ + 'transaction_id' => $transactionId, + ], + [ + 'gateway_id' => $gateway ? $gateway->id : null, + 'amount' => $amount, + 'fee' => $fee, + ] + ); + } + } + + /** + * Cancel subscription + */ + public static function cancelSubscription(Service $service) + { + foreach (Gateway::all() as $gateway) { + if (self::hasFunction($gateway, 'cancelSubscription')) { + if (self::getExtension('gateway', $gateway->extension, $gateway->settings)->cancelSubscription($service)) { + return true; + } + } + } + + return false; + } + + /* SERVER RELATED FUNCTIONS */ + + /** + * Get both properties and config options from order product and smash them together + */ + public static function getServiceProperties(Service $service) + { + $properties = []; + foreach ($service->properties as $property) { + $properties[$property->key] = $property->value; + } + foreach ($service->configs as $config) { + $properties[$config->configOption->env_variable] = $config->configValue->env_variable ?? $config->configValue->name; + } + + return $properties; + } + + protected static function checkServer(Service $service, $action) + { + $server = $service->product->server; + + if (!$server) { + throw new Exception('No server assigned to this product'); + } + + // Does server support this action? + if (!self::hasFunction($server, $action)) { + throw new Exception('Server does not support the action: ' . $action); + } + + return $server; + } + + /** + * Create server + */ + public static function createServer(Service $service) + { + $server = self::checkServer($service, 'createServer'); + + return self::getExtension('server', $server->extension, $server->settings)->createServer($service, self::settingsToArray($service->product->settings), self::getServiceProperties($service)); + } + + /** + * Suspend server + */ + public static function suspendServer(Service $service) + { + $server = self::checkServer($service, 'suspendServer'); + + return self::getExtension('server', $server->extension, $server->settings)->suspendServer($service, self::settingsToArray($service->product->settings), self::getServiceProperties($service)); + } + + /** + * Unsuspend server + */ + public static function unsuspendServer(Service $service) + { + $server = self::checkServer($service, 'unsuspendServer'); + + return self::getExtension('server', $server->extension, $server->settings)->unsuspendServer($service, self::settingsToArray($service->product->settings), self::getServiceProperties($service)); + } + + /** + * Terminate server + */ + public static function terminateServer(Service $service) + { + $server = self::checkServer($service, 'terminateServer'); + + return self::getExtension('server', $server->extension, $server->settings)->terminateServer($service, self::settingsToArray($service->product->settings), self::getServiceProperties($service)); + } + + /** + * Upgrade server + */ + public static function upgradeServer(Service $service) + { + $server = self::checkServer($service, 'upgradeServer'); + + return self::getExtension('server', $server->extension, $server->settings)->upgradeServer($service, self::settingsToArray($service->product->settings), self::getServiceProperties($service)); + } + + /** + * Get actions for service + */ + public static function getActions(Service $service) + { + $server = self::checkServer($service, 'getActions'); + + return self::getExtension('server', $server->extension, $server->settings)->getActions($service, self::settingsToArray($service->product->settings), self::getServiceProperties($service)); + } + + /** + * Get actions for service + */ + public static function getView(Service $service, $view) + { + $function = isset($view['function']) ? $view['function'] : 'getView'; + + $server = self::checkServer($service, $function); + + return self::getExtension('server', $server->extension, $server->settings)->$function($service, self::settingsToArray($service->product->settings), self::getServiceProperties($service), $view['name']); + } +} diff --git a/app/Helpers/NotificationHelper.php b/app/Helpers/NotificationHelper.php new file mode 100644 index 000000000..39f8141ec --- /dev/null +++ b/app/Helpers/NotificationHelper.php @@ -0,0 +1,152 @@ +first(); + if (!$emailTemplate || !$emailTemplate->enabled || config('settings.mail_disable')) { + return; + } + $mail = new Mail($emailTemplate, $data); + + $emailLog = EmailLog::create([ + 'user_id' => $user->id, + 'subject' => $mail->envelope()->subject, + 'to' => $user->email, + 'body' => $mail->render(), + ]); + + // Add the email log id to the payload + $mail->email_log_id = $emailLog->id; + + foreach ($attachments as $attachment) { + $mail->attachFromStorage($attachment['path'], $attachment['name'], $attachment['options'] ?? []); + } + + FacadesMail::to($user->email) + ->bcc($emailTemplate->bcc) + ->cc($emailTemplate->cc) + ->queue($mail); + } + + public static function loginDetectedNotification(User $user, array $data = []): void + { + self::sendEmailNotification('new_login_detected', $data, $user); + } + + public static function invoiceCreatedNotification(User $user, Invoice $invoice): void + { + $data = [ + 'invoice' => $invoice, + 'items' => $invoice->items, + 'total' => $invoice->formattedTotal, + 'has_subscription' => $invoice->items->filter(fn ($item) => $item->reference_type === Service::class && $item->reference->subscription_id)->isNotEmpty(), + ]; + + // Generate the invoice PDF + $pdf = PDF::generateInvoice($invoice); + // Generate path + if (!file_exists(storage_path('app/invoices'))) { + // Create the directory if it doesn't exist + mkdir(storage_path('app/invoices'), 0755, true); + } + // Save the PDF to a temporary location + $pdfPath = storage_path('app/invoices/' . $invoice->number . '.pdf'); + $pdf->save($pdfPath); + + // Attach the PDF to the email + $attachments = [ + [ + 'path' => 'invoices/' . $invoice->number . '.pdf', + 'name' => 'invoice.pdf', + ], + ]; + + self::sendEmailNotification('new_invoice_created', $data, $user, $attachments); + } + + public static function orderCreatedNotification(User $user, Order $order, array $data = []): void + { + $data = [ + 'order' => $order, + 'items' => $order->services, + 'total' => $order->formattedTotal, + ]; + self::sendEmailNotification('new_order_created', $data, $user); + } + + public static function serverCreatedNotification(User $user, Service $service, array $data = []): void + { + $data['service'] = $service; + self::sendEmailNotification('new_server_created', $data, $user); + } + + public static function serverSuspendedNotification(User $user, Service $service, array $data = []): void + { + $data['service'] = $service; + self::sendEmailNotification('server_suspended', $data, $user); + } + + public static function serverTerminatedNotification(User $user, Service $service, array $data = []): void + { + $data['service'] = $service; + self::sendEmailNotification('server_terminated', $data, $user); + } + + public static function ticketMessageNotification(User $user, TicketMessage $ticketMessage, array $data = []): void + { + $data['ticketMessage'] = $ticketMessage; + self::sendEmailNotification('new_ticket_message', $data, $user); + } + + public static function emailVerificationNotification(User $user, array $data = []): void + { + $data['user'] = $user; + $data['url'] = URL::temporarySignedRoute( + 'verification.verify', + Carbon::now()->addMinutes(Config::get('auth.verification.expire', 60)), + [ + 'id' => $user->getKey(), + 'hash' => sha1($user->email), + ] + ); + self::sendEmailNotification('email_verification', $data, $user); + } + + public static function passwordResetNotification(User $user, array $data = []): void + { + $data['user'] = $user; + self::sendEmailNotification('password_reset', $data, $user); + } + + public static function serviceCancellationReceivedNotification(User $user, \App\Models\ServiceCancellation $cancellation, array $data = []): void + { + $data['cancellation'] = $cancellation; + $data['service'] = $cancellation->service; + self::sendEmailNotification('service_cancellation_received', $data, $user); + } +} diff --git a/app/Http/Controllers/Api/Admin/InvoiceController.php b/app/Http/Controllers/Api/Admin/InvoiceController.php new file mode 100644 index 000000000..ed546684e --- /dev/null +++ b/app/Http/Controllers/Api/Admin/InvoiceController.php @@ -0,0 +1,90 @@ +allowedFilters(['id', 'currency_code', 'user_id', 'status']) + ->allowedIncludes($this->allowedIncludes(self::INCLUDES)) + ->allowedSorts(['id', 'created_at', 'updated_at', 'currency_code']) + ->simplePaginate(request('per_page', 15)); + + // Return the invoices as a JSON response + return InvoiceResource::collection($invoices); + } + + /** + * Create a new invoice + */ + public function store(CreateInvoiceRequest $request) + { + // Validate and create the invoice + $invoice = Invoice::create($request->validated()); + + // Return the created invoice as a JSON response + return new InvoiceResource($invoice); + } + + /** + * Show a specific invoice + */ + public function show(GetInvoiceRequest $request, Invoice $invoice) + { + $invoice = QueryBuilder::for(Invoice::class) + ->allowedIncludes($this->allowedIncludes(self::INCLUDES)) + ->findOrFail($invoice->id); + + // Return the invoice as a JSON response + return new InvoiceResource($invoice); + } + + /** + * Update a specific invoice + */ + public function update(UpdateInvoiceRequest $request, Invoice $invoice) + { + // Validate and update the invoice + $invoice->update($request->validated()); + + // Return the updated invoice as a JSON response + return new InvoiceResource($invoice); + } + + /** + * Delete a specific invoice + */ + public function destroy(DeleteInvoiceRequest $request, Invoice $invoice) + { + // Delete the invoice + $invoice->delete(); + + return $this->returnNoContent(); + } +} diff --git a/app/Http/Controllers/Api/Admin/OrderController.php b/app/Http/Controllers/Api/Admin/OrderController.php new file mode 100644 index 000000000..de4febcdd --- /dev/null +++ b/app/Http/Controllers/Api/Admin/OrderController.php @@ -0,0 +1,89 @@ +allowedFilters(['id', 'currency_code']) + ->allowedIncludes($this->allowedIncludes(self::INCLUDES)) + ->allowedSorts(['id', 'created_at', 'updated_at', 'currency_code']) + ->simplePaginate(request('per_page', 15)); + + // Return the orders as a JSON response + return OrderResource::collection($orders); + } + + /** + * Create a new order + */ + public function store(CreateOrderRequest $request) + { + // Validate and create the order + $order = Order::create($request->validated()); + + // Return the created order as a JSON response + return new OrderResource($order); + } + + /** + * Show a specific order + */ + public function show(GetOrderRequest $request, Order $order) + { + $order = QueryBuilder::for(Order::class) + ->allowedIncludes($this->allowedIncludes(self::INCLUDES)) + ->findOrFail($order->id); + + // Return the order as a JSON response + return new OrderResource($order); + } + + /** + * Update a specific order + */ + public function update(UpdateOrderRequest $request, Order $order) + { + // Validate and update the order + $order->update($request->validated()); + + // Return the updated order as a JSON response + return new OrderResource($order); + } + + /** + * Delete a specific order + */ + public function destroy(DeleteOrderRequest $request, Order $order) + { + // Delete the order + $order->delete(); + + return $this->returnNoContent(); + } +} diff --git a/app/Http/Controllers/Api/Admin/ServiceController.php b/app/Http/Controllers/Api/Admin/ServiceController.php new file mode 100644 index 000000000..6516bb171 --- /dev/null +++ b/app/Http/Controllers/Api/Admin/ServiceController.php @@ -0,0 +1,94 @@ +allowedFilters(['quantity', 'price', 'expires_at', 'subscription_id', 'status']) + ->allowedIncludes($this->allowedIncludes(self::INCLUDES)) + ->allowedSorts(['id', 'created_at', 'updated_at', 'expires_at', 'status']) + ->simplePaginate(request('per_page', 15)); + + // Return the services as a JSON response + return ServiceResource::collection($services); + } + + /** + * Create a new service + */ + public function store(CreateServiceRequest $request) + { + // Validate and create the service + $service = Service::create($request->validated()); + + // Return the created service as a JSON response + return new ServiceResource($service); + } + + /** + * Show a specific service + */ + public function show(GetServiceRequest $request, Service $service) + { + $service = QueryBuilder::for(Service::class) + ->allowedIncludes($this->allowedIncludes(self::INCLUDES)) + ->findOrFail($service->id); + + // Return the service as a JSON response + return new ServiceResource($service); + } + + /** + * Update a specific service + */ + public function update(UpdateServiceRequest $request, Service $service) + { + // Validate and update the service + $service->update($request->validated()); + + // Return the updated service as a JSON response + return new ServiceResource($service); + } + + /** + * Delete a specific service + */ + public function destroy(DeleteServiceRequest $request, Service $service) + { + // Delete the service + $service->delete(); + + return $this->returnNoContent(); + } +} diff --git a/app/Http/Controllers/Api/Admin/TicketController.php b/app/Http/Controllers/Api/Admin/TicketController.php new file mode 100644 index 000000000..b9ac30096 --- /dev/null +++ b/app/Http/Controllers/Api/Admin/TicketController.php @@ -0,0 +1,89 @@ +allowedFilters(['id', 'currency_code']) + ->allowedIncludes($this->allowedIncludes(self::INCLUDES)) + ->allowedSorts(['id', 'created_at', 'updated_at', 'currency_code']) + ->simplePaginate(request('per_page', 15)); + + // Return the tickets as a JSON response + return TicketResource::collection($tickets); + } + + /** + * Create a new ticket + */ + public function store(CreateTicketRequest $request) + { + // Validate and create the ticket + $ticket = Ticket::create($request->validated()); + + // Return the created ticket as a JSON response + return new TicketResource($ticket); + } + + /** + * Show a specific ticket + */ + public function show(GetTicketRequest $request, Ticket $ticket) + { + $ticket = QueryBuilder::for(Ticket::class) + ->allowedIncludes($this->allowedIncludes(self::INCLUDES)) + ->findOrFail($ticket->id); + + // Return the ticket as a JSON response + return new TicketResource($ticket); + } + + /** + * Update a specific ticket + */ + public function update(UpdateTicketRequest $request, Ticket $ticket) + { + // Validate and update the ticket + $ticket->update($request->validated()); + + // Return the updated ticket as a JSON response + return new TicketResource($ticket); + } + + /** + * Delete a specific ticket + */ + public function destroy(DeleteTicketRequest $request, Ticket $ticket) + { + // Delete the ticket + $ticket->delete(); + + return $this->returnNoContent(); + } +} diff --git a/app/Http/Controllers/Api/Admin/TicketMessageController.php b/app/Http/Controllers/Api/Admin/TicketMessageController.php new file mode 100644 index 000000000..86f6a9f62 --- /dev/null +++ b/app/Http/Controllers/Api/Admin/TicketMessageController.php @@ -0,0 +1,77 @@ +allowedFilters(['id', 'currency_code']) + ->allowedIncludes($this->allowedIncludes(self::INCLUDES)) + ->allowedSorts(['id', 'created_at', 'updated_at', 'currency_code']) + ->simplePaginate(request('per_page', 15)); + + // Return the ticketMessages as a JSON response + return TicketMessageResource::collection($ticketMessages); + } + + /** + * Create a new ticketMessage + */ + public function store(CreateTicketMessageRequest $request) + { + // Validate and create the ticketMessage + $ticketMessage = TicketMessage::create($request->validated()); + + // Return the created ticketMessage as a JSON response + return new TicketMessageResource($ticketMessage); + } + + /** + * Show a specific ticketMessage + */ + public function show(GetTicketMessageRequest $request, TicketMessage $ticketMessage) + { + $ticketMessage = QueryBuilder::for(TicketMessage::class) + ->allowedIncludes($this->allowedIncludes(self::INCLUDES)) + ->findOrFail($ticketMessage->id); + + // Return the ticketMessage as a JSON response + return new TicketMessageResource($ticketMessage); + } + + /** + * Delete a specific ticketMessage + */ + public function destroy(DeleteTicketMessageRequest $request, TicketMessage $ticketMessage) + { + // Delete the ticketMessage + $ticketMessage->delete(); + + return $this->returnNoContent(); + } +} diff --git a/app/Http/Controllers/Api/Admin/UserController.php b/app/Http/Controllers/Api/Admin/UserController.php new file mode 100644 index 000000000..96fa30e40 --- /dev/null +++ b/app/Http/Controllers/Api/Admin/UserController.php @@ -0,0 +1,95 @@ +allowedFilters(['first_name', 'last_name', 'email']) + ->allowedIncludes($this->allowedIncludes(self::INCLUDES)) + ->allowedSorts(['id', 'first_name', 'last_name', 'email', 'created_at']) + ->simplePaginate(request('per_page', 15)); + + // Return the users as a JSON response + return UserResource::collection($users); + } + + /** + * Create a new user + */ + public function store(CreateUserRequest $request) + { + // Validate and create the user + $user = User::create($request->validated()); + + // Return the created user as a JSON response + return new UserResource($user); + } + + /** + * Show a specific user + */ + public function show(GetUserRequest $request, User $user) + { + $user = QueryBuilder::for(User::class) + ->allowedIncludes($this->allowedIncludes(self::INCLUDES)) + ->findOrFail($user->id); + + // Return the user as a JSON response + return new UserResource($user); + } + + /** + * Update a specific user + */ + public function update(UpdateUserRequest $request, User $user) + { + // Validate and update the user + $user->update($request->validated()); + + // Return the updated user as a JSON response + return new UserResource($user); + } + + /** + * Delete a specific user + */ + public function destroy(DeleteUserRequest $request, User $user) + { + // Delete the user + $user->delete(); + + return $this->returnNoContent(); + } +} diff --git a/app/Http/Controllers/Api/ApiController.php b/app/Http/Controllers/Api/ApiController.php new file mode 100644 index 000000000..d5a0ee5f5 --- /dev/null +++ b/app/Http/Controllers/Api/ApiController.php @@ -0,0 +1,44 @@ + 'ticket_messages', + 'role' => 'roles', + 'user' => 'users', + 'ticket' => 'tickets', + ]; + + protected function allowedIncludes($includes = []): array + { + // Check if user has permission to include the specified relationships + $allowedIncludes = []; + + foreach ($includes as $include) { + // Check if the include is mapped to a specific relation + $relation = self::MAPPED_INCLUDES[$include] ?? $include; + + if ( + in_array('admin.' . $relation . '.view', request()->attributes->get('api_key_permissions', [])) || + !in_array('admin.' . $relation . '.view', config('permissions.api.admin', [])) + ) { + $allowedIncludes[] = $include; + } + } + + return $allowedIncludes; + } + + /** + * Return an HTTP/204 response for the API. + */ + protected function returnNoContent(): Response + { + return new Response('', Response::HTTP_NO_CONTENT); + } +} diff --git a/app/Http/Controllers/Api/ProfileController.php b/app/Http/Controllers/Api/ProfileController.php new file mode 100644 index 000000000..ccc40101e --- /dev/null +++ b/app/Http/Controllers/Api/ProfileController.php @@ -0,0 +1,16 @@ +json($request->user()); + } +} diff --git a/app/Http/Controllers/Controller.php b/app/Http/Controllers/Controller.php new file mode 100644 index 000000000..8677cd5ca --- /dev/null +++ b/app/Http/Controllers/Controller.php @@ -0,0 +1,8 @@ +discord_driver = Socialite::buildProvider(DiscordProvider::class, [ + 'client_id' => config('settings.oauth_discord_client_id'), + 'client_secret' => config('settings.oauth_discord_client_secret'), + 'redirect' => '/oauth/discord/callback', + ]); + + $this->github_driver = Socialite::buildProvider(GithubProvider::class, [ + 'client_id' => config('settings.oauth_github_client_id'), + 'client_secret' => config('settings.oauth_github_client_secret'), + 'redirect' => '/oauth/github/callback', + ]); + + $this->google_driver = Socialite::buildProvider(GoogleProvider::class, [ + 'client_id' => config('settings.oauth_google_client_id'), + 'client_secret' => config('settings.oauth_google_client_secret'), + 'redirect' => '/oauth/google/callback', + ]); + } + + public function redirect($provider) + { + if (!config("settings.oauth_$provider")) { + abort(404); + } + + return match ($provider) { + 'discord' => $this->discord_driver->scopes(['email'])->redirect(), + 'github' => $this->github_driver->scopes(['user:email'])->redirect(), + 'google' => $this->google_driver->scopes(['email'])->redirect(), + default => abort(404) + }; + } + + public function handle($provider) + { + if ($provider == 'discord') { + $oauth_user = $this->discord_driver->user(); + + if ($oauth_user->user['verified'] == false) { + return redirect()->route('login')->with('error', __('auth.oauth.unverified_discord_account')); + } + + $user = User::where('email', $oauth_user->email)->first(); + if (!$user) { + return redirect()->route('register')->with('error', __('auth.oauth.account_not_registered')); + } + + Auth::login($user, true); + + return redirect()->route('home'); + } elseif ($provider == 'google') { + $oauth_user = $this->google_driver->user(); + + $user = User::where('email', $oauth_user->email)->first(); + if (!$user) { + return redirect()->route('register')->with('error', __('auth.oauth.account_not_registered')); + } + + Auth::login($user, true); + + return redirect()->route('home'); + } elseif ($provider == 'github') { + $oauth_user = $this->github_driver->user(); + + $user = User::where('email', $oauth_user->email)->first(); + if (!$user) { + return redirect()->route('register')->with('error', __('auth.oauth.account_not_registered')); + } + + Auth::login($user, true); + + return redirect()->route('home'); + } else { + return redirect()->route('login'); + } + } +} diff --git a/app/Http/Middleware/Api/AdminApi.php b/app/Http/Middleware/Api/AdminApi.php new file mode 100644 index 000000000..5e9e4880e --- /dev/null +++ b/app/Http/Middleware/Api/AdminApi.php @@ -0,0 +1,44 @@ +bearerToken()) { + return response()->json(['error' => 'The request is missing a valid bearer token.'], 401); + } + + $token = ApiKey::where('token', hash('sha256', $request->bearerToken())) + ->where('enabled', true) + ->firstOr(function () { + return response()->json(['error' => 'The provided API key is invalid or has been disabled.'], 401); + }); + + // Check if the token is of type 'admin' + if ($token->type !== 'admin' || ($token->ip_addresses && !in_array($request->ip(), $token->ip_addresses))) { + return response()->json(['error' => 'You do not have permission to access this resource.'], 403); + } + + $token->last_used_at = now(); + $token->save(); + + // Attach the token to the request for further use + $request->attributes->set('api_key', $token); + $request->attributes->set('api_key_permissions', $token->permissions); + + return $next($request); + } +} diff --git a/app/Http/Middleware/EnsureUserHasPermissions.php b/app/Http/Middleware/EnsureUserHasPermissions.php new file mode 100644 index 000000000..3d8abcf97 --- /dev/null +++ b/app/Http/Middleware/EnsureUserHasPermissions.php @@ -0,0 +1,24 @@ +user()->hasPermission($permission)) { + return abort(403); + } + + return $next($request); + } +} diff --git a/app/Http/Middleware/ImpersonateMiddleware.php b/app/Http/Middleware/ImpersonateMiddleware.php new file mode 100644 index 000000000..10efeb062 --- /dev/null +++ b/app/Http/Middleware/ImpersonateMiddleware.php @@ -0,0 +1,30 @@ +has('impersonating')) { + if ($request->is('admin/*')) { + // Unset session + session()->forget('impersonating'); + } else { + Auth::onceUsingId(session('impersonating')); + } + } + + return $next($request); + } +} diff --git a/app/Http/Middleware/MustVerfiyEmail.php b/app/Http/Middleware/MustVerfiyEmail.php new file mode 100644 index 000000000..6381da838 --- /dev/null +++ b/app/Http/Middleware/MustVerfiyEmail.php @@ -0,0 +1,24 @@ +user() && !$request->user()->hasVerifiedEmail()) { + return redirect()->route('verification.notice'); + } + + return $next($request); + } +} diff --git a/app/Http/Middleware/ProxyMiddleware.php b/app/Http/Middleware/ProxyMiddleware.php new file mode 100644 index 000000000..9728c6465 --- /dev/null +++ b/app/Http/Middleware/ProxyMiddleware.php @@ -0,0 +1,35 @@ + 0) { + if (in_array('*', config('settings.trusted_proxies'))) { + $request->setTrustedProxies([$request->server->get('REMOTE_ADDR')], $this->headers); + } else { + $request->setTrustedProxies(config('settings.trusted_proxies'), $this->headers); + } + } + + return $next($request); + } +} diff --git a/app/Http/Middleware/SetLocale.php b/app/Http/Middleware/SetLocale.php new file mode 100644 index 000000000..eaa1e7899 --- /dev/null +++ b/app/Http/Middleware/SetLocale.php @@ -0,0 +1,20 @@ +has('locale')) { + $locale = session()->get('locale'); + App::setLocale($locale); + } + + return $next($request); + } +} diff --git a/app/Http/Requests/Api/Admin/AdminApiRequest.php b/app/Http/Requests/Api/Admin/AdminApiRequest.php new file mode 100644 index 000000000..cb1120c4f --- /dev/null +++ b/app/Http/Requests/Api/Admin/AdminApiRequest.php @@ -0,0 +1,26 @@ +permission, $this->instance()->attributes->get('api_key_permissions', [])); + } + + /** + * Default set of rules to apply to API requests. + */ + public function rules(): array + { + return []; + } +} diff --git a/app/Http/Requests/Api/Admin/Invoices/CreateInvoiceRequest.php b/app/Http/Requests/Api/Admin/Invoices/CreateInvoiceRequest.php new file mode 100644 index 000000000..76b64f4ec --- /dev/null +++ b/app/Http/Requests/Api/Admin/Invoices/CreateInvoiceRequest.php @@ -0,0 +1,33 @@ + 'required|exists:users,id', + /** + * @example USD + */ + 'currency_code' => 'required|string|exists:currencies,code', + 'due_at' => 'nullable|date', + /** + * @default pending + */ + 'status' => 'required|string|in:pending,paid,cancelled', // Status can be one of these values + ]; + } + + public function prepareForValidation() + { + $this->mergeIfMissing([ + 'status' => 'pending', // Default status to 'pending' if not provided + ]); + } +} diff --git a/app/Http/Requests/Api/Admin/Invoices/DeleteInvoiceRequest.php b/app/Http/Requests/Api/Admin/Invoices/DeleteInvoiceRequest.php new file mode 100644 index 000000000..fe4827002 --- /dev/null +++ b/app/Http/Requests/Api/Admin/Invoices/DeleteInvoiceRequest.php @@ -0,0 +1,10 @@ + 'required|exists:users,id', + /** + * @example USD + */ + 'currency_code' => 'required|string|exists:currencies,code', + 'due_at' => 'nullable|date', + /** + * @default pending + */ + 'status' => 'required|string|in:pending,paid,cancelled', // Status can be one of these values + ]; + } +} diff --git a/app/Http/Requests/Api/Admin/Orders/CreateOrderRequest.php b/app/Http/Requests/Api/Admin/Orders/CreateOrderRequest.php new file mode 100644 index 000000000..711e3f454 --- /dev/null +++ b/app/Http/Requests/Api/Admin/Orders/CreateOrderRequest.php @@ -0,0 +1,21 @@ + 'required|exists:users,id', + /** + * @example USD + */ + 'currency_code' => 'required|string|exists:currencies,code', + ]; + } +} diff --git a/app/Http/Requests/Api/Admin/Orders/DeleteOrderRequest.php b/app/Http/Requests/Api/Admin/Orders/DeleteOrderRequest.php new file mode 100644 index 000000000..e2fd54947 --- /dev/null +++ b/app/Http/Requests/Api/Admin/Orders/DeleteOrderRequest.php @@ -0,0 +1,10 @@ + 'required|exists:users,id', + /** + * @example USD + */ + 'currency_code' => 'required|string|exists:currencies,code', + ]; + } +} diff --git a/app/Http/Requests/Api/Admin/Services/CreateServiceRequest.php b/app/Http/Requests/Api/Admin/Services/CreateServiceRequest.php new file mode 100644 index 000000000..6e8693da1 --- /dev/null +++ b/app/Http/Requests/Api/Admin/Services/CreateServiceRequest.php @@ -0,0 +1,55 @@ + 'required|exists:products,id', + 'plan_id' => [ + 'required', + 'exists:plans,id', + function ($attribute, $value, $fail) { + $productId = $this->input('product_id'); + if ($productId && !\App\Models\Plan::where('id', $value)->where('priceable_type', Product::class)->where('priceable_id', $productId)->exists()) { + // Check if the plan belongs to the specified product + $fail('The selected plan does not belong to the specified product.'); + } + }, + ], + 'user_id' => 'required|exists:users,id', + /** + * @default 1 + */ + 'quantity' => 'required|integer|min:1', + /** + * @default pending + */ + 'status' => 'required|in:pending,active,cancelled,suspended', + 'expires_at' => 'nullable|date|after_or_equal:today', + /** + * @example USD + */ + 'currency_code' => 'required|string|exists:currencies,code', + 'price' => 'required|numeric|min:0', + 'coupon_id' => 'nullable|exists:coupons,id', + 'subscription_id' => 'nullable|string|max:255', + 'order_id' => 'nullable|exists:orders,id', + ]; + } + + public function prepareForValidation() + { + $this->mergeIfMissing([ + 'quantity' => 1, // Default quantity to 1 if not provided + 'status' => 'pending', // Default status to 'pending' if not provided + ]); + } +} diff --git a/app/Http/Requests/Api/Admin/Services/DeleteServiceRequest.php b/app/Http/Requests/Api/Admin/Services/DeleteServiceRequest.php new file mode 100644 index 000000000..859d202e2 --- /dev/null +++ b/app/Http/Requests/Api/Admin/Services/DeleteServiceRequest.php @@ -0,0 +1,10 @@ + 'required|exists:products,id', + 'plan_id' => [ + 'required', + 'exists:plans,id', + function ($attribute, $value, $fail) { + $productId = $this->input('product_id'); + if ($productId && !\App\Models\Plan::where('id', $value)->where('priceable_type', Product::class)->where('priceable_id', $productId)->exists()) { + // Check if the plan belongs to the specified product + $fail('The selected plan does not belong to the specified product.'); + } + }, + ], + 'user_id' => 'required|exists:users,id', + /** + * @default 1 + */ + 'quantity' => 'required|integer|min:1', + /** + * @default pending + */ + 'status' => 'required|in:pending,active,cancelled,suspended', + 'expires_at' => 'nullable|date|after_or_equal:today', + /** + * @example USD + */ + 'currency_code' => 'required|string|exists:currencies,code', + 'price' => 'required|numeric|min:0', + 'coupon_id' => 'nullable|exists:coupons,id', + 'subscription_id' => 'nullable|string|max:255', + 'order_id' => 'nullable|exists:orders,id', + ]; + } +} diff --git a/app/Http/Requests/Api/Admin/TicketMessages/CreateTicketMessageRequest.php b/app/Http/Requests/Api/Admin/TicketMessages/CreateTicketMessageRequest.php new file mode 100644 index 000000000..7402d615a --- /dev/null +++ b/app/Http/Requests/Api/Admin/TicketMessages/CreateTicketMessageRequest.php @@ -0,0 +1,19 @@ + 'required|string|max:5000', + 'user_id' => 'required|exists:users,id', + 'ticket_id' => 'required|exists:tickets,id', + ]; + } +} diff --git a/app/Http/Requests/Api/Admin/TicketMessages/DeleteTicketMessageRequest.php b/app/Http/Requests/Api/Admin/TicketMessages/DeleteTicketMessageRequest.php new file mode 100644 index 000000000..8eb79503b --- /dev/null +++ b/app/Http/Requests/Api/Admin/TicketMessages/DeleteTicketMessageRequest.php @@ -0,0 +1,10 @@ + 'required|string|max:255', + 'user_id' => 'required|exists:users,id', + 'department' => 'nullable|string|in:' . implode(',', config('settings.ticket_departments', [])), + 'priority' => 'required|string|in:low,medium,high', + 'status' => 'required|string|in:open,closed,replied', + ]; + } + + public function prepareForValidation() + { + $this->mergeIfMissing([ + 'priority' => 'medium', // Default priority to 'medium' if not provided + 'status' => 'open', // Default status to 'open' if not provided + ]); + } +} diff --git a/app/Http/Requests/Api/Admin/Tickets/DeleteTicketRequest.php b/app/Http/Requests/Api/Admin/Tickets/DeleteTicketRequest.php new file mode 100644 index 000000000..9eee623c3 --- /dev/null +++ b/app/Http/Requests/Api/Admin/Tickets/DeleteTicketRequest.php @@ -0,0 +1,10 @@ + 'required|string|max:255', + 'user_id' => 'required|exists:users,id', + 'department' => 'nullable|string|in:' . implode(',', config('settings.ticket_departments', [])), + 'priority' => 'required|string|in:low,medium,high', + 'status' => 'required|string|in:open,closed,replied', + ]; + } +} diff --git a/app/Http/Requests/Api/Admin/Users/CreateUserRequest.php b/app/Http/Requests/Api/Admin/Users/CreateUserRequest.php new file mode 100644 index 000000000..570c59359 --- /dev/null +++ b/app/Http/Requests/Api/Admin/Users/CreateUserRequest.php @@ -0,0 +1,22 @@ + 'nullable|string|max:255', + 'last_name' => 'nullable|string|max:255', + 'email' => 'required|email|max:255|unique:users,email', + 'password' => 'required|string|min:8', + 'email_verified_at' => 'nullable|date', + 'role_id' => 'nullable|exists:roles,id', + ]; + } +} diff --git a/app/Http/Requests/Api/Admin/Users/DeleteUserRequest.php b/app/Http/Requests/Api/Admin/Users/DeleteUserRequest.php new file mode 100644 index 000000000..099e5b978 --- /dev/null +++ b/app/Http/Requests/Api/Admin/Users/DeleteUserRequest.php @@ -0,0 +1,10 @@ + 'nullable|string|max:255', + 'last_name' => 'nullable|string|max:255', + 'email' => 'required|email|max:255|unique:users,email,' . $this->route()->parameter('user')->id, + 'password' => 'nullable|string|min:8', + 'email_verified_at' => 'nullable|date', + 'role_id' => 'nullable|exists:roles,id', + ]; + } +} diff --git a/app/Http/Resources/CategoryResource.php b/app/Http/Resources/CategoryResource.php new file mode 100644 index 000000000..c86229973 --- /dev/null +++ b/app/Http/Resources/CategoryResource.php @@ -0,0 +1,20 @@ + ProductResource::class, + ]; +} diff --git a/app/Http/Resources/CouponResource.php b/app/Http/Resources/CouponResource.php new file mode 100644 index 000000000..f6098fb87 --- /dev/null +++ b/app/Http/Resources/CouponResource.php @@ -0,0 +1,26 @@ + ServiceResource::class, + ]; +} diff --git a/app/Http/Resources/CreditResource.php b/app/Http/Resources/CreditResource.php new file mode 100644 index 000000000..5450eb4cf --- /dev/null +++ b/app/Http/Resources/CreditResource.php @@ -0,0 +1,20 @@ + UserResource::class, + ]; +} diff --git a/app/Http/Resources/InvoiceItemResource.php b/app/Http/Resources/InvoiceItemResource.php new file mode 100644 index 000000000..3782fbd4a --- /dev/null +++ b/app/Http/Resources/InvoiceItemResource.php @@ -0,0 +1,23 @@ + ServiceResource::class, + 'invoice' => InvoiceResource::class, + ]; +} diff --git a/app/Http/Resources/InvoiceResource.php b/app/Http/Resources/InvoiceResource.php new file mode 100644 index 000000000..754e8bf18 --- /dev/null +++ b/app/Http/Resources/InvoiceResource.php @@ -0,0 +1,25 @@ + UserResource::class, + 'items' => InvoiceItemResource::class, + ]; +} diff --git a/app/Http/Resources/OrderResource.php b/app/Http/Resources/OrderResource.php new file mode 100644 index 000000000..da8141695 --- /dev/null +++ b/app/Http/Resources/OrderResource.php @@ -0,0 +1,19 @@ + ServiceResource::class, + ]; +} diff --git a/app/Http/Resources/ProductResource.php b/app/Http/Resources/ProductResource.php new file mode 100644 index 000000000..fab10acbf --- /dev/null +++ b/app/Http/Resources/ProductResource.php @@ -0,0 +1,20 @@ + CategoryResource::class, + ]; +} diff --git a/app/Http/Resources/PropertyResource.php b/app/Http/Resources/PropertyResource.php new file mode 100644 index 000000000..6aa2b68f4 --- /dev/null +++ b/app/Http/Resources/PropertyResource.php @@ -0,0 +1,17 @@ + PropertyResource::class, + 'coupon' => CouponResource::class, + 'user' => UserResource::class, + 'order' => OrderResource::class, + 'product' => ProductResource::class, + 'invoices' => InvoiceResource::class, + ]; +} diff --git a/app/Http/Resources/TicketMessageResource.php b/app/Http/Resources/TicketMessageResource.php new file mode 100644 index 000000000..9e3392f3d --- /dev/null +++ b/app/Http/Resources/TicketMessageResource.php @@ -0,0 +1,20 @@ + UserResource::class, + 'ticket' => TicketResource::class, + ]; +} diff --git a/app/Http/Resources/TicketResource.php b/app/Http/Resources/TicketResource.php new file mode 100644 index 000000000..9ba5fc710 --- /dev/null +++ b/app/Http/Resources/TicketResource.php @@ -0,0 +1,24 @@ + TicketMessageResource::class, + 'user' => UserResource::class, + 'assigned_to' => UserResource::class, + ]; +} diff --git a/app/Http/Resources/UserResource.php b/app/Http/Resources/UserResource.php new file mode 100644 index 000000000..bda0720e4 --- /dev/null +++ b/app/Http/Resources/UserResource.php @@ -0,0 +1,29 @@ + PropertyResource::class, + 'orders' => OrderResource::class, + 'services' => ServiceResource::class, + 'invoices' => InvoiceResource::class, + 'tickets' => TicketResource::class, + 'credits' => CreditResource::class, + 'role' => RoleResource::class, + ]; +} diff --git a/app/Jobs/Server/CreateJob.php b/app/Jobs/Server/CreateJob.php new file mode 100644 index 000000000..699f54a99 --- /dev/null +++ b/app/Jobs/Server/CreateJob.php @@ -0,0 +1,43 @@ +service); + } catch (\Exception $e) { + if ($e->getMessage() == 'No server assigned to this product') { + return; + } + } + + if ($this->sendNotification && isset($data)) { + NotificationHelper::serverCreatedNotification($this->service->user, $this->service, is_array($data) ? $data : []); + } + } +} diff --git a/app/Jobs/Server/SuspendJob.php b/app/Jobs/Server/SuspendJob.php new file mode 100644 index 000000000..c1f6dde67 --- /dev/null +++ b/app/Jobs/Server/SuspendJob.php @@ -0,0 +1,40 @@ +service); + } catch (\Exception $e) { + if ($e->getMessage() == 'No server assigned to this product') { + return; + } + } + + if ($this->sendNotification && isset($data)) { + NotificationHelper::serverSuspendedNotification($this->service->user, $this->service, is_array($data) ? $data : []); + } + } +} diff --git a/app/Jobs/Server/TerminateJob.php b/app/Jobs/Server/TerminateJob.php new file mode 100644 index 000000000..5196c4f29 --- /dev/null +++ b/app/Jobs/Server/TerminateJob.php @@ -0,0 +1,40 @@ +service); + } catch (\Exception $e) { + if ($e->getMessage() == 'No server assigned to this product') { + return; + } + } + + if ($this->sendNotification && isset($data)) { + NotificationHelper::serverTerminatedNotification($this->service->user, $this->service, is_array($data) ? $data : []); + } + } +} diff --git a/app/Jobs/Server/UnsuspendJob.php b/app/Jobs/Server/UnsuspendJob.php new file mode 100644 index 000000000..4581ca300 --- /dev/null +++ b/app/Jobs/Server/UnsuspendJob.php @@ -0,0 +1,35 @@ +service); + } catch (\Exception $e) { + if ($e->getMessage() == 'No server assigned to this product') { + return; + } + } + } +} diff --git a/app/Jobs/Server/UpgradeJob.php b/app/Jobs/Server/UpgradeJob.php new file mode 100644 index 000000000..38050ff2f --- /dev/null +++ b/app/Jobs/Server/UpgradeJob.php @@ -0,0 +1,36 @@ +service); + } catch (\Exception $e) { + if ($e->getMessage() == 'No server assigned to this product') { + return; + } + } + } +} diff --git a/app/Listeners/CancellationCreatedListener.php b/app/Listeners/CancellationCreatedListener.php new file mode 100644 index 000000000..c61cb8914 --- /dev/null +++ b/app/Listeners/CancellationCreatedListener.php @@ -0,0 +1,25 @@ +cancellation->type == 'immediate') { + TerminateJob::dispatch($event->cancellation->service); + + $event->cancellation->service->update([ + 'status' => 'cancelled', + ]); + } + // If the cancellation is scheduled, we don't need to do anything as it will be handled by the cron job + } +} diff --git a/app/Listeners/FlushCacheListener.php b/app/Listeners/FlushCacheListener.php new file mode 100644 index 000000000..c6c249ed4 --- /dev/null +++ b/app/Listeners/FlushCacheListener.php @@ -0,0 +1,18 @@ +terminating(fn () => SettingsProvider::flushCache()); + $flushed = true; + } + } +} diff --git a/app/Listeners/InvoiceCreatingListener.php b/app/Listeners/InvoiceCreatingListener.php new file mode 100644 index 000000000..276fc01b1 --- /dev/null +++ b/app/Listeners/InvoiceCreatingListener.php @@ -0,0 +1,38 @@ + 'invoice_number', + ], [ + 'value' => $number, + ]); + // Pad the invoice number with leading zeros + $paddedNumber = str_pad($number, config('settings.invoice_number_padding', 1), '0', STR_PAD_LEFT); + + // Format the invoice number + $formattedNumber = config('settings.invoice_number_format', '{number}'); + $formattedNumber = str_replace('{number}', $paddedNumber, $formattedNumber); + $formattedNumber = str_replace('{year}', now()->format('Y'), $formattedNumber); + $formattedNumber = str_replace('{month}', now()->format('m'), $formattedNumber); + $formattedNumber = str_replace('{day}', now()->format('d'), $formattedNumber); + + // Set the invoice number + $event->invoice->number = $formattedNumber; + } +} diff --git a/app/Listeners/InvoicePaidListener.php b/app/Listeners/InvoicePaidListener.php new file mode 100644 index 000000000..5042dc385 --- /dev/null +++ b/app/Listeners/InvoicePaidListener.php @@ -0,0 +1,77 @@ + active etc.) + $event->invoice->items->each(function ($item) { + if ($item->reference_type == Service::class) { + $service = $item->reference; + if (!$service) { + return; + } + if ($service->product->server) { + if ($service->status == Service::STATUS_SUSPENDED) { + UnsuspendJob::dispatch($service); + } elseif ($service->status == Service::STATUS_PENDING) { + CreateJob::dispatch($service); + } + } + $service->status = Service::STATUS_ACTIVE; + $service->expires_at = $service->calculateNextDueDate(); + $service->save(); + } elseif ($item->reference_type == ServiceUpgrade::class) { + $serviceUpgrade = $item->reference; + if (!$serviceUpgrade || $serviceUpgrade->status !== ServiceUpgrade::STATUS_PENDING) { + return; + } + $serviceUpgrade->status = ServiceUpgrade::STATUS_COMPLETED; + $serviceUpgrade->save(); + + $service = $serviceUpgrade->service; + $service->plan_id = $serviceUpgrade->plan_id; + $service->product_id = $serviceUpgrade->product_id; + $service->save(); + + foreach ($serviceUpgrade->configs as $config) { + $service->configs()->updateOrCreate( + ['config_option_id' => $config->config_option_id], + ['config_value_id' => $config->config_value_id] + ); + } + $service->recalculatePrice(); + if ($service->product->server) { + UpgradeJob::dispatch($service); + } + } elseif ($item->reference_type == Credit::class) { + // Check if user has credits in this currency + $user = $item->invoice->user; + $credit = $user->credits()->where('currency_code', $item->invoice->currency_code)->first(); + + if ($credit) { + $credit->amount += $item->price; + $credit->save(); + } else { + $user->credits()->create([ + 'currency_code' => $item->invoice->currency_code, + 'amount' => $item->price, + ]); + } + } + }); + } +} diff --git a/app/Listeners/InvoiceTransactionCreatedListener.php b/app/Listeners/InvoiceTransactionCreatedListener.php new file mode 100644 index 000000000..b71a81928 --- /dev/null +++ b/app/Listeners/InvoiceTransactionCreatedListener.php @@ -0,0 +1,20 @@ +invoiceTransaction->invoice; + if ($invoice->remaining <= 0 && $invoice->status !== 'paid') { + $invoice->status = 'paid'; + $invoice->save(); + } + } +} diff --git a/app/Listeners/RequestListener.php b/app/Listeners/RequestListener.php new file mode 100644 index 000000000..711ba5230 --- /dev/null +++ b/app/Listeners/RequestListener.php @@ -0,0 +1,254 @@ +recordFailedRequest($event); + } elseif ($event instanceof ResponseReceived) { + $this->recordResponse($event); + } + } + + /** + * Record a HTTP Client connection failed request event. + * + * @return void + */ + public function recordFailedRequest(ConnectionFailed $event) + { + DebugLog::create([ + 'type' => 'http', + 'context' => $this->encodeContext([ + 'method' => $event->request->method(), + 'url' => $event->request->url(), + 'headers' => $this->headers($event->request->headers()), + 'payload' => $this->payload($this->input($event->request)), + 'response' => 'Connection Failed', + ]), + ]); + } + + /** + * Record a HTTP Client response. + * + * @return void + */ + public function recordResponse(ResponseReceived $event) + { + DebugLog::create([ + 'type' => 'http', + 'context' => $this->encodeContext([ + 'method' => $event->request->method(), + 'url' => $event->request->url(), + 'headers' => $this->headers($event->request->headers()), + 'payload' => $this->payload($this->input($event->request)), + 'response_status' => $event->response->status(), + 'response_headers' => $this->headers($event->response->headers()), + 'response' => $this->response($event->response), + 'duration' => $this->duration($event->response), + ]), + ]); + } + + protected function encodeContext(array $context) + { + array_walk_recursive($context, function (&$item) { + if (is_string($item)) { + $item = mb_convert_encoding($item, 'UTF-8', 'UTF-8'); + } + }); + + return $context; + } + + /** + * Determine if the content is within the set limits. + * + * @param string $content + * @return bool + */ + public function contentWithinLimits($content) + { + $limit = 64; + + return mb_strlen($content) / 1000 <= $limit; + } + + /** + * Format the given response object. + * + * @return array|string + */ + protected function response(Response $response) + { + $content = $response->body(); + + $stream = $response->toPsrResponse()->getBody(); + + if ($stream->isSeekable()) { + $stream->rewind(); + } + + if (is_string($content)) { + if ( + is_array(json_decode($content, true)) && + json_last_error() === JSON_ERROR_NONE + ) { + return $this->contentWithinLimits($content) + ? $this->hideParameters(json_decode($content, true), self::hiddenParamaters) + : 'Hidden Content'; + } + + if (Str::startsWith(strtolower($response->header('Content-Type') ?? ''), 'text/plain')) { + return $this->contentWithinLimits($content) ? $content : 'Hidden Content'; + } + } + + if ($response->redirect()) { + return 'Redirected to ' . $response->header('Location'); + } + + if (empty($content)) { + return 'Empty Response'; + } + + return 'HTML Response'; + } + + /** + * Format the given headers. + * + * @param array $headers + * @return array + */ + protected function headers($headers) + { + $headerNames = collect($headers)->keys()->map(function ($headerName) { + return strtolower($headerName); + })->toArray(); + + $headerValues = collect($headers) + ->map(fn ($header) => implode(', ', $header)) + ->all(); + + $headers = array_combine($headerNames, $headerValues); + + return $this->hideParameters( + $headers, + self::hiddenParamaters + ); + } + + /** + * Format the given payload. + * + * @param array $payload + * @return array + */ + protected function payload($payload) + { + return $this->hideParameters( + $payload, + self::hiddenParamaters + ); + } + + /** + * Hide the given parameters. + * + * @param array $data + * @param array $hidden + * @return mixed + */ + protected function hideParameters($data, $hidden) + { + foreach ($hidden as $parameter) { + if (Arr::get($data, $parameter)) { + Arr::set($data, $parameter, '********'); + } + } + + return $data; + } + + /** + * Extract the input from the given request. + * + * @return array + */ + protected function input(Request $request) + { + if (!$request->isMultipart()) { + return $request->data(); + } + + return collect($request->data())->mapWithKeys(function ($data) { + if ($data['contents'] instanceof File) { + $value = [ + 'name' => $data['filename'] ?? $data['contents']->getClientOriginalName(), + 'size' => ($data['contents']->getSize() / 1000) . 'KB', + 'headers' => $data['headers'] ?? [], + ]; + } elseif (is_resource($data['contents'])) { + $filesize = @filesize(stream_get_meta_data($data['contents'])['uri']); + + $value = [ + 'name' => $data['filename'] ?? null, + 'size' => $filesize ? ($filesize / 1000) . 'KB' : null, + 'headers' => $data['headers'] ?? [], + ]; + } elseif (json_encode($data['contents']) === false) { + $value = [ + 'name' => $data['filename'] ?? null, + 'size' => (strlen($data['contents']) / 1000) . 'KB', + 'headers' => $data['headers'] ?? [], + ]; + } else { + $value = $data['contents']; + } + + return [$data['name'] => $value]; + })->toArray(); + } + + /** + * Get the request duration in milliseconds. + * + * @return int|null + */ + protected function duration(Response $response) + { + if ( + property_exists($response, 'transferStats') && + $response->transferStats && + $response->transferStats->getTransferTime() + ) { + return floor($response->transferStats->getTransferTime() * 1000); + } + } +} diff --git a/app/Listeners/SendMailListener.php b/app/Listeners/SendMailListener.php new file mode 100644 index 000000000..49a0cdafa --- /dev/null +++ b/app/Listeners/SendMailListener.php @@ -0,0 +1,50 @@ +sendEmail === false) { + return; + } + + $invoice = $event->invoice; + NotificationHelper::invoiceCreatedNotification($invoice->user, $invoice); + } elseif ($event instanceof UserCreated) { + $user = $event->user; + NotificationHelper::emailVerificationNotification($user); + } elseif ($event instanceof OrderFinalized) { + if ($event->sendEmail === false) { + return; + } + $order = $event->order; + NotificationHelper::orderCreatedNotification($order->user, $order); + } elseif ($event instanceof Login) { + $user = $event->user; + $data = [ + 'ip' => request()->ip(), + 'device' => (new Session(['user_agent' => request()->userAgent()]))->getFormattedDeviceAttribute(), + 'time' => now()->format('Y-m-d H:i:s'), + ]; + NotificationHelper::loginDetectedNotification($user, $data); + } elseif ($event instanceof CancellationCreated) { + $cancellation = $event->cancellation; + NotificationHelper::serviceCancellationReceivedNotification($cancellation->service->user, $cancellation); + } + } +} diff --git a/app/Listeners/TicketMessageCreatedListener.php b/app/Listeners/TicketMessageCreatedListener.php new file mode 100644 index 000000000..d1882b67b --- /dev/null +++ b/app/Listeners/TicketMessageCreatedListener.php @@ -0,0 +1,35 @@ +ticketMessage; + + if (!$ticketMessage || !$ticketMessage->ticket || !$ticketMessage->ticket->user) { + // Early return if anything is missing + return; + } + + $ticket = $ticketMessage->ticket; + $ticketOwner = $ticket->user; + + if ($ticketOwner->id !== $ticketMessage->user_id) { + // Update ticket status + $ticket->update(['status' => 'replied']); + + // Send notification to ticket owner + NotificationHelper::ticketMessageNotification($ticketOwner, $ticketMessage); + } else { + $ticket->update(['status' => 'open']); + } + } +} diff --git a/app/Livewire/Auth/Login.php b/app/Livewire/Auth/Login.php new file mode 100644 index 000000000..d3b3a994f --- /dev/null +++ b/app/Livewire/Auth/Login.php @@ -0,0 +1,72 @@ +captcha(); + $this->validate(); + + if (RateLimiter::tooManyAttempts('login:' . $this->email, 5)) { + $this->addError('email', 'Too many login attempts. Please try again in 60 seconds.'); + + return; + } + + RateLimiter::increment('login:' . $this->email); + + if (!Auth::attempt($this->only('email', 'password'), $this->remember)) { + $this->addError('email', 'These credentials do not match our records.'); + + return; + } + + // Check 2FA + if (Auth::user()->tfa_secret) { + Session::put('2fa', [ + 'user_id' => Auth::id(), + 'remember' => $this->remember, + 'expires' => now()->addMinutes(5), + ]); + + Auth::logout(); + + return $this->redirect(route('2fa'), true); + } + + RateLimiter::clear('login:' . $this->email); + + event(new \App\Events\Auth\Login(User::find(Auth::id()))); + + $intendedUrl = session()->pull('url.intended', default: route('dashboard')); + $isAdminRoute = str_starts_with($intendedUrl, url('/admin')); + + // Redirect normally if it is an admin route, otherwise navigate using livewire + return $this->redirect($intendedUrl, navigate: !$isAdminRoute); + } + + public function render() + { + return view('auth.login'); + } +} diff --git a/app/Livewire/Auth/Logout.php b/app/Livewire/Auth/Logout.php new file mode 100644 index 000000000..e7c5be639 --- /dev/null +++ b/app/Livewire/Auth/Logout.php @@ -0,0 +1,21 @@ +captcha(); + + $this->validate([ + 'email' => 'required|email', + ]); + + $rateLimitKey = 'password-reset:' . request()->ip(); + + if (RateLimiter::tooManyAttempts($rateLimitKey, 3)) { + $this->addError('email', 'Too many password reset attempts. Please try again later.'); + + return; + } + + RateLimiter::hit($rateLimitKey, 60); + + // Find the user + $user = User::where('email', $this->email)->first(); + + if ($user && !$user?->role) { + NotificationHelper::passwordResetNotification($user, ['url' => url(route('password.reset', [ + 'token' => Password::createToken($user), + 'email' => $user->email, + ], false))]); + } + + $this->notify('If the email address is associated with an account, you will receive an email with instructions on how to reset your password.', 'success'); + } + + public function render() + { + return view('auth.password.request'); + } +} diff --git a/app/Livewire/Auth/Password/Reset.php b/app/Livewire/Auth/Password/Reset.php new file mode 100644 index 000000000..6642f6ede --- /dev/null +++ b/app/Livewire/Auth/Password/Reset.php @@ -0,0 +1,69 @@ +token || !Request::has('email')) { + return abort(404); + } + // Validate token + if (!Password::tokenExists(User::where('email', $this->email)->firstOrFail(), $this->token)) { + return abort(404); + } + } + + public function submit() + { + $this->validate(); + + $this->captcha(); + + $status = Password::reset( + ['email' => $this->email, 'password' => $this->password, 'password_confirmation' => $this->password_confirmation, 'token' => $this->token], + function ($user) { + $user->forceFill([ + 'password' => Hash::make($this->password), + ])->setRememberToken(Str::random(60)); + + $user->save(); + + event(new PasswordReset($user)); + } + ); + + return $status === Password::PASSWORD_RESET ? $this->redirect(route('login')) : $this->notify($status, 'error'); + } + + public function render() + { + return view('auth.password.reset'); + } +} diff --git a/app/Livewire/Auth/Register.php b/app/Livewire/Auth/Register.php new file mode 100644 index 000000000..ff08948dd --- /dev/null +++ b/app/Livewire/Auth/Register.php @@ -0,0 +1,66 @@ +initializeProperties(null, User::class); + } + + public function rules() + { + return array_merge([ + 'first_name' => 'required|string|max:255', + 'last_name' => 'required|string|max:255', + 'email' => 'required|email|max:255|unique:users', + 'password' => 'required|string|min:8|confirmed', + ], $this->getRulesForProperties()); + } + + public function submit() + { + $validatedData = $this->validate(); + + $user = User::create([ + 'first_name' => $this->first_name, + 'last_name' => $this->last_name, + 'email' => $this->email, + 'password' => Hash::make($this->password), + ]); + + if (array_key_exists('properties', $validatedData)) { + $this->updateProperties($user, $validatedData['properties']); + } + + Auth::login($user); + + return $this->redirectIntended(route('dashboard'), true); + } + + public function render() + { + return view('auth.register'); + } +} diff --git a/app/Livewire/Auth/Tfa.php b/app/Livewire/Auth/Tfa.php new file mode 100644 index 000000000..27a2da356 --- /dev/null +++ b/app/Livewire/Auth/Tfa.php @@ -0,0 +1,74 @@ +redirect(route('login'), true); + } + } + + public function verify() + { + $this->validate([ + 'code' => 'required|numeric', + ]); + + if (RateLimiter::tooManyAttempts('2fa', 5)) { + $this->addError('code', 'Too many attempts.'); + + return; + } + + RateLimiter::increment('2fa'); + + $session = Session::get('2fa'); + $user = User::findOrfail($session['user_id']); + + if (!$user->tfa_secret) { + return $this->redirect(route('login'), true); + } + + $tfa = new TwoFactorAuth(new EndroidQrCodeProvider, config('app.name')); + if (!$tfa->verifyCode($user->tfa_secret, $this->code)) { + return $this->addError('code', 'Invalid code.'); + } + + // Check if code has been used before, preventing replay attacks + if (Cache::has('tfa_used_code_' . $user->id . '_' . $this->code)) { + return $this->addError('code', 'Invalid code.'); + } + // Mark code as used for 60 seconds just in case + Cache::put('tfa_used_code_' . $user->id . '_' . $this->code, true, 60); + + Auth::loginUsingId($user->id, $session['remember']); + + event(new \App\Events\Auth\Login(User::find(Auth::id()))); + + Session::forget('2fa'); + + RateLimiter::clear('2fa'); + + return $this->redirect(route('dashboard'), true); + } + + public function render() + { + return view('auth.2fa'); + } +} diff --git a/app/Livewire/Auth/VerifyEmail.php b/app/Livewire/Auth/VerifyEmail.php new file mode 100644 index 000000000..facfb1322 --- /dev/null +++ b/app/Livewire/Auth/VerifyEmail.php @@ -0,0 +1,43 @@ +hasVerifiedEmail()) { + return redirect()->route('dashboard'); + } + } + + public function submit() + { + $this->captcha(); + + if (RateLimiter::tooManyAttempts('email-verification', 1)) { + $this->addError('code', 'Too many attempts. Try again later.'); + + return; + } + + NotificationHelper::emailVerificationNotification(Auth::user()); + + RateLimiter::hit('email-verification', 120); + + $this->notify('Verification email sent.'); + } + + public function render() + { + return view('auth.verify-email'); + } +} diff --git a/app/Livewire/Cart.php b/app/Livewire/Cart.php new file mode 100644 index 000000000..c93b5e107 --- /dev/null +++ b/app/Livewire/Cart.php @@ -0,0 +1,299 @@ +coupon = Session::get('coupon'); + } + $this->updateTotal(); + } + + private function updateTotal() + { + if (ClassesCart::get()->isEmpty()) { + $this->total = null; + + return; + } + $this->total = new Price(['price' => ClassesCart::get()->sum(fn ($item) => $item->price->price * $item->quantity), 'currency' => ClassesCart::get()->first()->price->currency]); + $this->gateways = ExtensionHelper::getCheckoutGateways(ClassesCart::get(), 'cart'); + if (count($this->gateways) > 0 && !array_search($this->gateway, array_column($this->gateways, 'id')) !== false) { + $this->gateway = $this->gateways[0]->id; + } + } + + public function applyCoupon() + { + if ($this->coupon && Session::has('coupon')) { + return $this->notify('Coupon code already applied', 'error'); + } + try { + ClassesCart::applyCoupon($this->coupon); + } catch (DisplayException $e) { + $this->notify($e->getMessage(), 'error'); + $this->coupon = null; + + return; + } + $this->coupon = Session::get('coupon'); + $this->updateTotal(); + $this->notify('Coupon code applied successfully', 'success'); + } + + public function removeCoupon() + { + if (!$this->coupon || !Session::has('coupon')) { + return $this->notify('No coupon code applied', 'error'); + } + ClassesCart::removeCoupon(); + $this->coupon = null; + $this->updateTotal(); + $this->notify('Coupon code removed successfully', 'success'); + } + + public function removeProduct($index) + { + ClassesCart::remove($index); + $this->updateTotal(); + } + + public function updateQuantity($index, $quantity) + { + if (ClassesCart::get()->get($index)->product->allow_quantity !== 'combined') { + return; + } + if ($quantity < 1) { + $this->removeProduct($index); + + return; + } + ClassesCart::get()->get($index)->quantity = $quantity; + session(['cart' => ClassesCart::get()->toArray()]); + $this->updateTotal(); + } + + // Checkout + public function checkout() + { + if (ClassesCart::get()->isEmpty() || Session::has('cart') === false) { + return $this->notify('Your cart is empty', 'error'); + } + if (!Auth::check()) { + return redirect()->guest('login'); + } + if (config('settings.mail_must_verify') && !Auth::user()->hasVerifiedEmail()) { + return redirect()->route('verification.notice'); + } + if (config('settings.tos') && !$this->tos) { + return $this->notify('You must accept the terms of service', 'error'); + } + + // Re-validate coupon if one exists + if (Session::has('coupon') && !ClassesCart::validateAndRefreshCoupon()) { + $this->coupon = null; + $this->updateTotal(); + + return $this->notify('This coupon can no longer be used', 'error'); + } + + // Start database transaction + DB::beginTransaction(); + try { + $user = User::where('id', Auth::id())->lockForUpdate()->first(); + // Lock the orderproducts + foreach (ClassesCart::get() as $item) { + if ( + $item->product->per_user_limit > 0 && ($user->services->where('product_id', $item->product->id)->count() >= $item->product->per_user_limit || + ClassesCart::get()->filter(fn ($it) => $it->product->id == $item->product->id)->sum(fn ($it) => $it->quantity) + $user->services->where('product_id', $item->product->id)->count() > $item->product->per_user_limit + ) + ) { + throw new DisplayException(__('product.user_limit', ['product' => $item->product->name])); + } + if ($item->product->stock !== null) { + if ($item->product->stock < $item->quantity) { + throw new DisplayException(__('product.out_of_stock', ['product' => $item->product->name])); + } + + $item->product->stock -= $item->quantity; + $item->product->save(); + } + } + // Create the order + $order = new Order([ + 'user_id' => $user->id, + 'currency_code' => $this->total->currency->code, + ]); + $order->save(); + + // Create the invoice + if ($this->total->price > 0) { + $invoice = new Invoice([ + 'user_id' => $user->id, + 'due_at' => now()->addDays(7), + 'currency_code' => $this->total->currency->code, + ]); + $invoice->save(); + } + + // Create the services + foreach (ClassesCart::get() as $item) { + // Is it a lifetime coupon, then we can adjust the price of the service + if ($this->coupon && $this->coupon->recurring != 1) { + $price = $item->price->price - $item->price->setup_fee; + } else { + $price = $item->price->original_price - $item->price->original_setup_fee; + } + // Create the service + $service = $order->services()->create([ + 'user_id' => $user->id, + 'currency_code' => $this->total->currency->code, + 'product_id' => $item->product->id, + 'plan_id' => $item->plan->id, + 'price' => $price, + 'quantity' => $item->quantity, + 'coupon_id' => Session::has('coupon') ? Session::get('coupon')->id : null, + ]); + + foreach ($item->checkoutConfig as $key => $value) { + $service->properties()->updateOrCreate([ + 'key' => $key, + ], [ + 'value' => $value, + ]); + } + + foreach ($item->configOptions as $configOption) { + if (in_array($configOption->option_type, ['text', 'number', 'checkbox'])) { + if (!isset($configOption->value)) { + continue; + } + $service->properties()->updateOrCreate([ + 'key' => $configOption->option_env_variable ? $configOption->option_env_variable : $configOption->option_name, + ], [ + 'name' => $configOption->option_name, + 'value' => $configOption->value, + ]); + + continue; + } + + $service->configs()->create([ + 'config_option_id' => $configOption->option_id, + 'config_value_id' => $configOption->value, + ]); + } + + // Create the invoice items + if ($item->price->price > 0) { + $invoice->items()->create([ + 'reference_id' => $service->id, + 'reference_type' => Service::class, + 'price' => $item->price->price, + 'quantity' => $item->quantity, + 'description' => $service->description, + ]); + } else { + // We'll make the service active immediately + if ($service->product->server) { + CreateJob::dispatch($service); + } + $service->status = Service::STATUS_ACTIVE; + $service->expires_at = $service->calculateNextDueDate(); + $service->save(); + } + } + + // We don't wanna use credits if the total price is 0, duh + if ($this->use_credits && $this->total->price > 0) { + $credit = Auth::user()->credits()->where('currency_code', $this->total->currency->code)->first(); + if ($credit && $credit->amount > 0) { + // Is it more credits or less credits than the total price? + if ($credit->amount >= $this->total->price) { + $credit->amount -= $this->total->price; + $credit->save(); + ExtensionHelper::addPayment($invoice->id, null, amount: $this->total->price); + } else { + $this->total->price -= $credit->amount; + ExtensionHelper::addPayment($invoice->id, null, amount: $credit->amount); + $credit->amount = 0; + $credit->save(); + } + } + } + + // Commit the transaction + DB::commit(); + + // Clear the cart + Session::forget('cart'); + Session::forget('coupon'); + + // Pass the gateway to the payment page + Session::put(['gateway' => $this->gateway]); + + if ($this->total->price == 0) { + // Is it only one item? Then redirect to the service page + if ($order->services->count() == 1) { + return $this->redirect(route('services.show', $order->services->first()), true); + } + + return $this->redirect(route('services'), true); + } else { + return $this->redirect(route('invoices.show', $invoice) . '?pay'); + } + } catch (\Exception $e) { + // Rollback the transaction + DB::rollBack(); + // Return error message + // Is it a real error or a validation error? + // If it's a validation error, you can use the $this->addError() method to display the error message to the user. + if ($e instanceof DisplayException) { + return $this->notify($e->getMessage(), 'error'); + } else { + Log::error($e); + $this->notify('An error occurred while processing your order. Please try again later.'); + } + throw $e; + } + } + + public function render() + { + return view('cart'); + } +} diff --git a/app/Livewire/Client/Account.php b/app/Livewire/Client/Account.php new file mode 100644 index 000000000..1772b588d --- /dev/null +++ b/app/Livewire/Client/Account.php @@ -0,0 +1,62 @@ +first_name = $user->first_name; + $this->last_name = $user->last_name; + $this->email = $user->email; + + $this->initializeProperties($user, $user::class); + } + + public function rules() + { + return array_merge([ + 'first_name' => 'required|string|max:255', + 'last_name' => 'required|string|max:255', + 'email' => 'required|email|max:255|unique:users,email,' . Auth::id(), + ], $this->getRulesForProperties()); + } + + public function validationAttributes() + { + return $this->getAttributesForProperties(); + } + + public function submit() + { + $validatedData = $this->validate(); + + /** @var \App\Models\User $user */ + $user = Auth::user(); + $user->update($validatedData); + + if (array_key_exists('properties', $validatedData)) { + $this->updateProperties($user, $validatedData['properties']); + } + + $this->notify(__('Account updated successfully.')); + } + + public function render() + { + return view('client.account.index')->layoutData([ + 'sidebar' => true, + ]); + } +} diff --git a/app/Livewire/Client/Credits.php b/app/Livewire/Client/Credits.php new file mode 100644 index 000000000..e1a148fac --- /dev/null +++ b/app/Livewire/Client/Credits.php @@ -0,0 +1,113 @@ +route('account'); + } + + $this->amount = config('settings.credits_minimum_deposit'); + $this->currency = session('currency', config('settings.default_currency')); + $this->gateways = ExtensionHelper::getCheckoutGateways([], 'credits'); + if (count($this->gateways) > 0 && !array_search($this->gateway, array_column($this->gateways, 'id')) !== false) { + $this->gateway = $this->gateways[0]->id; + } + } + + public function addCredit() + { + $this->validate([ + 'currency' => 'required|exists:currencies,code', + 'amount' => 'required|numeric|min:' . config('settings.credits_minimum_deposit') . '|max:' . config('settings.credits_maximum_deposit'), + 'gateway' => 'required|in:' . implode(',', array_column($this->gateways, 'id')), + ]); + + if (Auth::user()->credits()->where('currency_code', $this->currency)->exists()) { + // Check if the current credits + the new credits exceed the maximum credits allowed + if (Auth::user()->credits()->where('currency_code', $this->currency)->sum('amount') + $this->amount > config('settings.credits_maximum_credit')) { + $this->notify('You cannot exceed the maximum credits allowed.', 'error'); + + return; + } + } + + // Create invoice + DB::beginTransaction(); + + try { + $invoice = Invoice::create([ + 'user_id' => Auth::id(), + 'currency_code' => $this->currency, + 'due_at' => now(), + ]); + + $invoice->items()->create([ + 'description' => __('account.credit_deposit', ['currency' => $this->currency]), + 'quantity' => 1, + 'price' => $this->amount, + 'reference_type' => Credit::class, + ]); + + DB::commit(); + + Session::put(['gateway' => $this->gateway]); + + // Redirect to the invoices page and pay the invoice + if ($this->gateway) { + $pay = ExtensionHelper::pay(Gateway::where('id', $this->gateway)->first(), $invoice->fresh()); + if (is_string($pay)) { + return $this->redirect($pay); + } + } + + return $this->redirect(route('invoices.show', $invoice) . '?gateway=' . $this->gateway . '&pay', true); + } catch (\Exception $e) { + // Rollback the transaction + DB::rollBack(); + // Return error message + // Is it a real error or a validation error? + // If it's a validation error, you can use the $this->addError() method to display the error message to the user. + if ($e instanceof DisplayException) { + $this->notify($e->getMessage(), 'error'); + } else { + Log::error($e); + $this->notify('An error occurred while processing your order. Please try again later.'); + } + throw $e; + } + } + + public function render() + { + return view('client.account.credits')->layoutData([ + 'sidebar' => true, + ]); + } +} diff --git a/app/Livewire/Client/Security.php b/app/Livewire/Client/Security.php new file mode 100644 index 000000000..196565442 --- /dev/null +++ b/app/Livewire/Client/Security.php @@ -0,0 +1,117 @@ +twoFactorEnabled = Auth::user()->tfa_secret ? true : false; + } + + public function rules() + { + return [ + 'current_password' => 'required|string', + 'password' => 'required|string|min:8|confirmed', + ]; + } + + public function changePassword() + { + $this->validate(); + + if (!Hash::check($this->current_password, Auth::user()->password)) { + return $this->notify(__('account.notifications.password_incorrect'), 'error'); + } + + Auth::user()->update([ + 'password' => Hash::make($this->password), + ]); + + $this->notify(__('account.notifications.password_changed')); + + $this->reset('current_password', 'password', 'password_confirmation'); + } + + public function enableTwoFactor() + { + if ($this->showEnableTwoFactor) { + $this->validate([ + 'twoFactorCode' => 'required|string', + ]); + + $tfa = new TwoFactorAuth(new EndroidQrCodeProvider, config('app.name')); + if ($tfa->verifyCode($this->twoFactorData['secret'], $this->twoFactorCode)) { + Auth::user()->update([ + 'tfa_secret' => $this->twoFactorData['secret'], + ]); + + $this->notify(__('account.notifications.two_factor_enabled')); + + $this->twoFactorEnabled = true; + $this->showEnableTwoFactor = false; + } else { + $this->notify(__('account.notifications.two_factor_code_incorrect'), 'error'); + } + } else { + $tfa = new TwoFactorAuth(new EndroidQrCodeProvider, config('app.name')); + $secret = $tfa->createSecret(); + $this->twoFactorData = [ + 'secret' => $secret, + 'image' => $tfa->getQRCodeImageAsDataUri(Auth::user()->email, $secret), + ]; + + $this->showEnableTwoFactor = true; + } + } + + public function disableTwoFactor() + { + Auth::user()->update([ + 'tfa_secret' => null, + ]); + + $this->notify(__('account.notifications.two_factor_disabled')); + + $this->twoFactorEnabled = false; + } + + public function logoutSession(Session $session) + { + $session->delete(); + + $this->notify(__('account.notifications.session_logged_out')); + } + + public function render() + { + return view('client.account.security')->layoutData([ + 'sidebar' => true, + ]); + } +} diff --git a/app/Livewire/Component.php b/app/Livewire/Component.php new file mode 100644 index 000000000..dbcd65286 --- /dev/null +++ b/app/Livewire/Component.php @@ -0,0 +1,23 @@ +notify($e->getMessage(), 'error'); + $stopPropagation(); + } + } +} diff --git a/app/Livewire/ComponentWithProperties.php b/app/Livewire/ComponentWithProperties.php new file mode 100644 index 000000000..8bd56d269 --- /dev/null +++ b/app/Livewire/ComponentWithProperties.php @@ -0,0 +1,86 @@ +custom_properties = \App\Models\CustomProperty::where('model', $morphClass)->get(); + if ($model) { + $this->properties = $model + ->properties->mapWithKeys(function ($property) { + return [$property->key => $property->value]; + }) + ->toArray(); + } + } + + public function getRulesForProperties(): array + { + return $this->custom_properties->mapWithKeys(function ($property) { + return ["properties.$property->key" => ($property->required ? 'required|' : 'nullable|') . "$property->validation"]; + })->toArray(); + } + + /** + * Returns the attributes of the properties to show in error messages + */ + public function getAttributesForProperties(): array + { + return $this->custom_properties->mapWithKeys(function ($property) { + return ["properties.$property->key" => $property->name]; + })->toArray(); + } + + /** + * Updates the properties of the model + * + * @param \Illuminate\Database\Eloquent\Model $model + * @param array $properties + */ + public function updateProperties($model, $properties) + { + $properties = collect($properties)->map(function ($value, $key) use ($model) { + $custom_property = $this->custom_properties->where('key', $key)->first(); + + return [ + 'key' => $key, + 'value' => $value, + 'model_id' => $model->id, + 'model_type' => $model->getMorphClass(), + 'name' => $custom_property->name, + 'custom_property_id' => $custom_property->id, + ]; + })->toArray(); + + $model->properties()->upsert($properties, uniqueBy: [ + 'key', + 'model_id', + 'model_type', + ], update: [ + 'name', + 'value', + 'model_id', + 'model_type', + 'custom_property_id', + ]); + + event(new PropertiesUpdated($model, $properties)); + } +} diff --git a/app/Livewire/Components/Cart.php b/app/Livewire/Components/Cart.php new file mode 100644 index 000000000..c5bf187e5 --- /dev/null +++ b/app/Livewire/Components/Cart.php @@ -0,0 +1,31 @@ +cartCount = ClassesCart::get()->count(); + if (ClassesCart::get()->isEmpty()) { + $this->skipRender(); + } + } + + #[On('cartUpdated')] + public function onCartUpdated() + { + $this->cartCount = ClassesCart::get()->count(); + } + + public function render() + { + return view('components.cart'); + } +} diff --git a/app/Livewire/Components/CurrencySwitch.php b/app/Livewire/Components/CurrencySwitch.php new file mode 100644 index 000000000..000e33fe7 --- /dev/null +++ b/app/Livewire/Components/CurrencySwitch.php @@ -0,0 +1,44 @@ +currentCurrency = session('currency', config('settings.default_currency')); + $this->currencies = Currency::all()->map(fn ($currency) => [ + 'value' => $currency->code, + 'label' => $currency->code, + ])->values()->toArray(); + if (Cart::get()->isNotEmpty() || count($this->currencies) <= 1) { + $this->skipRender(); + } + } + + public function updatedCurrentCurrency($currency) + { + if (Cart::get()->isNotEmpty()) { + $this->notify('You cannot change the currency while there are items in the cart.', 'error'); + $this->currentCurrency = session('currency', config('settings.default_currency')); + + return; + } + session(['currency' => $currency]); + + return $this->redirect(request()->header('Referer', '/'), navigate: true); + } + + public function render() + { + return view('components.currency-switch'); + } +} diff --git a/app/Livewire/Components/LanguageSwitch.php b/app/Livewire/Components/LanguageSwitch.php new file mode 100644 index 000000000..db79e97fc --- /dev/null +++ b/app/Livewire/Components/LanguageSwitch.php @@ -0,0 +1,34 @@ +currentLocale = session('locale', config('app.locale')); + } + + public function updatedCurrentLocale($locale) + { + if (!array_key_exists($locale, config('app.available_locales', []))) { + return; + } + + session(['locale' => $locale]); + app()->setLocale($locale); + + return $this->redirect(request()->header('Referer', '/'), navigate: true); + } + + public function render() + { + $locales = config('app.available_locales'); + + return view('components.language-switch', compact('locales')); + } +} diff --git a/app/Livewire/Dashboard.php b/app/Livewire/Dashboard.php new file mode 100644 index 000000000..df3715dd1 --- /dev/null +++ b/app/Livewire/Dashboard.php @@ -0,0 +1,15 @@ +layoutData([ + 'sidebar' => true, + ]); + } +} diff --git a/app/Livewire/Home.php b/app/Livewire/Home.php new file mode 100644 index 000000000..ae135b410 --- /dev/null +++ b/app/Livewire/Home.php @@ -0,0 +1,21 @@ + Category::whereNull('parent_id')->where(function ($query) { + $query->whereHas('children')->orWhereHas('products', function ($query) { + $query->where('hidden', false); + }); + })->orderBy('sort')->get(), + 'title' => 'Home', + ]); + } +} diff --git a/app/Livewire/Invoices/Index.php b/app/Livewire/Invoices/Index.php new file mode 100644 index 000000000..c9be4dfe1 --- /dev/null +++ b/app/Livewire/Invoices/Index.php @@ -0,0 +1,22 @@ + Auth::user()->invoices()->orderBy('id', 'desc')->paginate(config('settings.pagination')), + ])->layoutData([ + 'title' => __('invoices.invoices'), + 'sidebar' => true, + ]); + } +} diff --git a/app/Livewire/Invoices/Show.php b/app/Livewire/Invoices/Show.php new file mode 100644 index 000000000..4c7071ce6 --- /dev/null +++ b/app/Livewire/Invoices/Show.php @@ -0,0 +1,132 @@ +gateway = $sessionGateway; + } + + if ($this->invoice->status === 'pending') { + $this->gateways = ExtensionHelper::getCheckoutGateways($this->invoice->items, 'invoice'); + if (count($this->gateways) > 0 && !array_search($this->gateway, array_column($this->gateways, 'id')) !== false) { + $this->gateway = $this->gateways[0]->id; + } + if ($sessionGateway && Request::has('pay')) { + $this->pay(); + } + } + if (Request::has('checkPayment') && $this->invoice->status === 'pending') { + $this->checkPayment = true; + } + + // We don't want to toggle use_credits before $this->pay() is called, otherwise it will always paid with credits + $this->use_credits = true; + $hasCredits = $this->invoice->items()->where('reference_type', Credit::class)->exists(); + if ($hasCredits) { + $this->use_credits = false; + } + } + + public function pay() + { + if ($this->use_credits) { + $credit = Auth::user()->credits()->where('currency_code', $this->invoice->currency_code)->first(); + if ($credit && $credit->amount > 0) { + // Is it more credits or less credits than the total price? + if ($credit->amount >= $this->invoice->remaining) { + $credit->amount -= $this->invoice->remaining; + $credit->save(); + ExtensionHelper::addPayment($this->invoice->id, null, amount: $this->invoice->remaining); + + return $this->redirect(route('invoices.show', $this->invoice), true); + } else { + ExtensionHelper::addPayment($this->invoice->id, null, amount: $credit->amount); + $credit->amount = 0; + $credit->save(); + + $this->invoice = $this->invoice->fresh(); + } + } + } + + if ($this->invoice->status !== 'pending') { + return $this->notify(__('This invoice cannot be paid.'), 'error'); + } + if ($this->checkPayment) { + $this->checkPayment = false; + } + $this->validate([ + 'gateway' => 'required', + ]); + + $this->pay = ExtensionHelper::pay(Gateway::where('id', $this->gateway)->first(), $this->invoice); + + if (is_string($this->pay)) { + $this->redirect($this->pay); + } + } + + public function exitPay() + { + $this->pay = null; + // Dispatch event so extensions can do their thing + $this->dispatch('invoice.payment.cancelled', $this->invoice); + // Refresh invoice status + $this->redirect(route('invoices.show', $this->invoice), true); + } + + public function checkPaymentStatus() + { + $this->invoice->refresh(); + if ($this->invoice->status === 'paid') { + $this->notify(__('The invoice has been paid.'), 'success'); + $this->checkPayment = false; + } + } + + public function render() + { + return view('invoices.show')->layoutData([ + 'title' => __('invoices.invoice', ['id' => $this->invoice->number]), + 'sidebar' => true, + ]); + } + + public function downloadPDF() + { + return response()->streamDownload(function () { + echo PDF::generateInvoice($this->invoice)->stream(); + }, 'invoice-' . $this->invoice->number . '.pdf'); + } +} diff --git a/app/Livewire/Invoices/Widget.php b/app/Livewire/Invoices/Widget.php new file mode 100644 index 000000000..e37ba4e18 --- /dev/null +++ b/app/Livewire/Invoices/Widget.php @@ -0,0 +1,21 @@ + Auth::user()->invoices()->orderBy('id', 'desc')->where('status', '=', 'pending')->paginate(config('settings.pagination')), + ])->layoutData([ + 'title' => __('invoices.invoices'), + ]); + } +} diff --git a/app/Livewire/Products/Checkout.php b/app/Livewire/Products/Checkout.php new file mode 100644 index 000000000..e61939068 --- /dev/null +++ b/app/Livewire/Products/Checkout.php @@ -0,0 +1,242 @@ +product = $this->category->products()->where('slug', $product)->firstOrFail(); + if ($this->product->stock === 0) { + return $this->redirect(route('products.show', ['category' => $this->category, 'product' => $this->product]), true); + } + + // Is there a existing item in the cart? + if (Cart::get()->has($this->cartProductKey) && Cart::get()->get($this->cartProductKey)->product->id === $this->product->id) { + $item = Cart::get()->get($this->cartProductKey); + // Get the item from the cart + $this->plan = $item->plan->fresh(); + $this->plan_id = $this->plan->id; + $this->configOptions = $item->configOptions->mapWithKeys(function ($option) { + return [$option->option_id => $option->value]; + }); + $this->checkoutConfig = (array) $item->checkoutConfig; + } else { + // Set the first plan as default + $this->plan = $this->plan_id ? $this->product->plans->findOrFail($this->plan_id) : $this->product->plans->first(); + $this->plan_id = $this->plan->id; + + // Prepare the config options + $this->configOptions = $this->product->configOptions->mapWithKeys(function ($option) { + if (in_array($option->type, ['text', 'number', 'checkbox'])) { + return [$option->id => $this->configOptions[$option->id] ?? null]; + } + + return [$option->id => $this->configOptions[$option->id] ?? $option->children->first()->id]; + })->toArray(); + foreach ($this->getCheckoutConfig() as $config) { + if (in_array($config['type'], ['select', 'radio'])) { + $this->checkoutConfig[$config['name']] = $this->checkoutConfig[$config['name']] ?? $config['default'] ?? array_key_first($config['options']); + } else { + $this->checkoutConfig[$config['name']] = $this->checkoutConfig[$config['name']] ?? $config['default'] ?? null; + } + } + } + // Update the pricing + $this->updatePricing(); + + // As there is only one plan, config options and checkout config, we can directly call the checkout method to avoid confusion + // This is only done when the user is not editing the cart item + if ($this->product->plans->count() === 1 && empty($this->configOptions) && empty($this->checkoutConfig)) { + $this->checkout(); + } + } + + public function updatePricing() + { + $total = $this->plan->price()->price + $this->product->configOptions->sum(function ($option) { + if ($option->type === 'checkbox' && (isset($this->configOptions[$option->id]) && $this->configOptions[$option->id])) { + return $option->children->first()?->price(billing_period: $this->plan->billing_period, billing_unit: $this->plan->billing_unit)->price; + } + if (in_array($option->type, ['text', 'number', 'checkbox'])) { + return 0; + } + + return $option->children->where('id', $this->configOptions[$option->id])->first()?->price(billing_period: $this->plan->billing_period, billing_unit: $this->plan->billing_unit)->price; + }); + $setup_fee = $this->plan->price()->setup_fee + $this->product->configOptions->sum(function ($option) { + if (in_array($option->type, ['text', 'number', 'checkbox'])) { + return $option->children->first()?->price(billing_period: $this->plan->billing_period, billing_unit: $this->plan->billing_unit)->setup_fee; + } + + return $option->children->where('id', $this->configOptions[$option->id])->first()?->price(billing_period: $this->plan->billing_period, billing_unit: $this->plan->billing_unit)->setup_fee; + }); + $this->total = new Price([ + 'price' => $total + $setup_fee, + 'currency' => $this->plan->price()->currency, + 'setup_fee' => $setup_fee, + ], apply_exclusive_tax: true); + } + + // On change of the plan, update the config options + public function updatedPlanId($value) + { + $this->plan = Plan::findOrFail($value); + $this->updatePricing(); + } + + // On change of the config options, update the pricing + public function updatedConfigOptions() + { + $this->updatePricing(); + } + + public function getCheckoutConfig() + { + return once(fn () => ExtensionHelper::getCheckoutConfig($this->product, $this->checkoutConfig)); + } + + public function rules() + { + $rules = [ + 'plan_id' => [ + 'required', + Rule::exists('plans', 'id')->where(function ($query) { + $query->where('priceable_id', $this->product->id)->where('priceable_type', get_class($this->product)); + }), + ], + ]; + foreach ($this->product->configOptions as $option) { + if (in_array($option->type, ['text', 'number'])) { + $rules["configOptions.{$option->id}"] = ['required']; + } elseif ($option->type === 'checkbox') { + } else { + $rules["configOptions.{$option->id}"] = ['required', 'exists:config_options,id']; + } + } + foreach ($this->getCheckoutConfig() as $key => $config) { + $validationRules = []; + if ($config['required'] ?? false) { + $validationRules[] = 'required'; + } + if (isset($config['type'])) { + switch ($config['type']) { + case 'text': + case 'number': + $validationRules[] = 'string'; + break; + case 'select': + case 'radio': + $validationRules[] = 'in:' . implode(',', array_keys($config['options'])); + break; + case 'checkbox': + $validationRules[] = 'nullable'; + $validationRules[] = 'boolean'; + break; + } + } + if (isset($config['validation'])) { + if (is_array($config['validation'])) { + $validationRules = array_merge($validationRules, $config['validation']); + } else { + // Is validation seperated by |? + $validationRules = array_merge($validationRules, explode('|', $config['validation'])); + } + } + if (count($validationRules) > 0) { + $rules["checkoutConfig.{$config['name']}"] = $validationRules; + } + } + + return $rules; + } + + public function attributes() + { + $messages = []; + foreach ($this->product->configOptions as $option) { + $messages["configOptions.{$option->id}"] = $option->name; + } + foreach ($this->getCheckoutConfig() as $key => $config) { + $messages["checkoutConfig.{$config['name']}"] = $config['label'] ?? $config['name']; + } + + return $messages; + } + + public function checkout() + { + // Do the checkout + // First we validate the plans + $this->validate(attributes: $this->attributes()); + + // Change configOptions so they also contain the name of the option (resulting in less database calls = faster speeds) + $configOptions = $this->product->configOptions->map(function ($option) { + if (in_array($option->type, ['text', 'number', 'checkbox'])) { + return (object) ['option_id' => $option->id, 'option_name' => $option->name, 'option_type' => $option->type, 'option_env_variable' => $option->env_variable, 'value' => $this->configOptions[$option->id], 'value_name' => $this->configOptions[$option->id]]; + } + + return (object) ['option_id' => $option->id, 'option_name' => $option->name, 'option_type' => $option->type, 'option_env_variable' => $option->env_variable, 'value' => $this->configOptions[$option->id], 'value_name' => $option->children->where('id', $this->configOptions[$option->id])->first()->name]; + }); + + Cart::add($this->product, $this->plan, $configOptions, $this->checkoutConfig, $this->total, key: $this->cartProductKey); + + $this->dispatch('cartUpdated'); + + return $this->redirect(route('cart'), true); + } + + private function total() + { + // A class isn't allowed to be a Livewire property + $totalPrice['price'] = new ModelsPrice(['price' => $this->total]); + $totalPrice['currency'] = $this->plan->price()->currency; + $totalPrice = new Price((object) $totalPrice); + + return $totalPrice; + } + + public function render() + { + return view('products.checkout')->layoutData([ + 'title' => $this->product->name, + 'image' => $this->product->image ? Storage::url($this->product->image) : null, + ]); + } +} diff --git a/app/Livewire/Products/Index.php b/app/Livewire/Products/Index.php new file mode 100644 index 000000000..58acf58d8 --- /dev/null +++ b/app/Livewire/Products/Index.php @@ -0,0 +1,44 @@ +products = $this->category->products()->where('hidden', false)->with('category')->orderBy('sort')->get(); + $this->childCategories = $this->category->children()->where(function ($query) { + $query->whereHas('children')->orWhereHas('products', function ($query) { + $query->where('hidden', false); + }); + })->orderBy('sort')->get(); + $this->categories = Category::whereNull('parent_id')->where(function ($query) { + $query->whereHas('children')->orWhereHas('products', function ($query) { + $query->where('hidden', false); + }); + })->orderBy('sort')->get(); + if (count($this->products) === 0 && count($this->childCategories) === 0) { + abort(404); + } + } + + public function render() + { + return view('products.index')->layoutData([ + 'title' => $this->category->name, + 'image' => $this->category->image ? Storage::url($this->category->image) : null, + ]); + } +} diff --git a/app/Livewire/Products/Show.php b/app/Livewire/Products/Show.php new file mode 100644 index 000000000..70051e53f --- /dev/null +++ b/app/Livewire/Products/Show.php @@ -0,0 +1,27 @@ +product = $this->category->products()->where('slug', $product)->where('hidden', false)->firstOrFail(); + } + + public function render() + { + return view('products.show')->layoutData([ + 'title' => $this->product->name, + 'image' => $this->product->image ? Storage::url($this->product->image) : null, + ]); + } +} diff --git a/app/Livewire/Services/Cancel.php b/app/Livewire/Services/Cancel.php new file mode 100644 index 000000000..367feab59 --- /dev/null +++ b/app/Livewire/Services/Cancel.php @@ -0,0 +1,45 @@ +authorize('view', $this->service); + + $this->validate(); + + // Event hook will handle the cancellation (if its immediate or end of period) + ServiceCancellation::create([ + 'service_id' => $this->service->id, + 'type' => $this->type, + 'reason' => $this->reason, + ]); + + $this->notify(__('services.cancellation_requested'), 'success'); + + $this->redirect(route('services.show', $this->service), true); + } + + public function render() + { + return view('services.cancel')->layoutData([ + 'title' => __('services.cancellation', ['service' => $this->service->product->name]), + 'sidebar' => true, + ]); + } +} diff --git a/app/Livewire/Services/Index.php b/app/Livewire/Services/Index.php new file mode 100644 index 000000000..266958340 --- /dev/null +++ b/app/Livewire/Services/Index.php @@ -0,0 +1,37 @@ +services() + ->with([ + 'product.category', + 'product.server', + 'plan', + 'proxmoxServer', + ]) + ->orderBy('created_at', 'desc'); + + if ($this->status) { + $query->where('status', $this->status); + } + + return view('services.index', [ + 'services' => $query->paginate(config('settings.pagination')), + ])->layoutData([ + 'title' => 'Services', + 'sidebar' => true, + ]); + } +} diff --git a/app/Livewire/Services/Show.php b/app/Livewire/Services/Show.php new file mode 100644 index 000000000..7a91f07f1 --- /dev/null +++ b/app/Livewire/Services/Show.php @@ -0,0 +1,117 @@ +proxmoxServer = ProxmoxServer::where('service_id', $this->service->id)->first(); + + // Only fetch the actions if the service is active + if ($this->service->status == Service::STATUS_ACTIVE) { + $actions = []; + try { + $actions = ExtensionHelper::getActions($this->service); + } catch (\Exception $e) { + } + // separate the actions into buttons and views + foreach ($actions as $action) { + if ($action['type'] == 'button') { + $this->buttons[] = $action; + } elseif ($action['type'] == 'view') { + $this->views[] = $action; + } + } + $this->currentView = $this->currentView ?? ($this->views[0]['name'] ?? null); + } + } + + public function changeView($view) + { + if (!$view) { + return; + } + if ($this->currentView === $view || !in_array($view, array_column($this->views, 'name'))) { + return $this->skipRender(); + } + $this->currentView = $view; + } + + public function updatedShowCancel($value) + { + if (!$this->service->cancellable) { + $this->notify('This service cannot be cancelled', 'error'); + $this->showCancel = false; + + return; + } + } + + public function goto($function) + { + // Check if function is allowed + if (!in_array($function, array_column($this->buttons, 'function'))) { + $this->notify('This action is not allowed', 'error'); + + return; + } + $result = ExtensionHelper::callService($this->service, $function); + // If its a response, return it + if (!is_string($result)) { + return $result; + } + $this->redirect($result); + } + + public function render() + { + $view = null; + $previousView = $this->currentView; + + if ($this->currentView) { + try { + // Search array for the current view + $currentViewObj = $this->views[array_search($this->currentView, array_column($this->views, 'name'))] ?? null; + if (!$currentViewObj) { + throw new \Exception('View not found'); + } + $view = ExtensionHelper::getView($this->service, $currentViewObj); + } catch (\Exception $e) { + if ($previousView !== $this->views[0]['name'] ?? null) { + $this->notify('Got an error while trying to load the view', 'error'); + } + $this->currentView = $this->views[0]['name'] ?? null; + } + } + + return view('services.show', ['extensionView' => $view])->layoutData([ + 'title' => 'Services', + 'sidebar' => true, + ]); + } +} diff --git a/app/Livewire/Services/Upgrade.php b/app/Livewire/Services/Upgrade.php new file mode 100644 index 000000000..d4be2bc42 --- /dev/null +++ b/app/Livewire/Services/Upgrade.php @@ -0,0 +1,263 @@ +authorize('view', $this->service); + + if (!$this->service->upgradable) { + $this->notify('This service is not upgradable.', 'error'); + + return $this->redirect(route('services.show', $this->service), true); + } + $this->upgradeProduct = $this->service->product; + $this->upgrade = $this->service->product->id; + $this->totalToday(); + + // We only have upgrabble config options if the product has any + if ($this->service->productUpgrades()->count() === 0) { + $this->nextStep(); + } + } + + #[Computed] + public function totalToday() + { + $upgrade = new ServiceUpgrade([ + 'service' => $this->service, + 'product' => $this->upgradeProduct, + ]); + + $total = $upgrade->calculateProratedAmount( + $this->service->product, + $this->upgradeProduct + )->price; + + // Calculate prices for config options + foreach ($this->configOptions as $optionId => $value) { + $option = $this->upgradeProduct->upgradableConfigOptions->where('id', $optionId)->first(); + if (!$option || !$option->children->contains('id', $value)) { + continue; + } + + $oldPrice = $this->service->configs->where('config_option_id', $optionId)->first(); + + $ctotal = $upgrade->calculateProratedAmount( + $oldPrice ? $oldPrice->configValue : null, + $option->children->find($value) + ); + $total += $ctotal->price; + } + + return new Price([ + 'price' => $total, + 'currency' => $this->service->currency, + ]); + } + + // When upgrade changes, update the upgradeProduct + public function updatedUpgrade($upgrade) + { + // Check if the upgrade is valid + if (!$this->service->productUpgrades()->contains($upgrade) && $upgrade != $this->service->product_id) { + $this->notify('Invalid upgrade.', 'error'); + + return; + } + $this->upgradeProduct = Product::findOrFail($upgrade); + } + + public function nextStep() + { + $currentConfigOptions = $this->service->configs->pluck('config_value_id', 'config_option_id')->toArray(); + $this->configOptions = $this->upgradeProduct->upgradableConfigOptions->mapWithKeys(function ($option) use ($currentConfigOptions) { + return [ + $option->id => $currentConfigOptions[$option->id] + ?? $this->configOptions[$option->id] + ?? $option->children->first()->id, + ]; + })->toArray(); + + $this->step++; + } + + public function rules() + { + $rules = [ + 'upgradeProduct.id' => [ + 'required', + function ($attribute, $value, $fail) { + $plan = $this->upgradeProduct->availablePlans() + ->where('billing_period', $this->service->plan->billing_period) + ->where('billing_unit', $this->service->plan->billing_unit) + ->first(); + if (!$plan) { + $fail(__('Invalid upgrade.')); + } + }, + ], + + ]; + foreach ($this->upgradeProduct->upgradableConfigOptions as $option) { + if (in_array($option->type, ['text', 'number'])) { + $rules["configOptions.{$option->id}"] = ['required']; + } elseif ($option->type === 'checkbox') { + } else { + $rules["configOptions.{$option->id}"] = ['required', 'exists:config_options,id']; + } + } + + return $rules; + } + + public function doUpgrade() + { + if (!$this->service->upgradable) { + $this->notify('This service is not upgradable.', 'error'); + + return $this->redirect(route('services.show', $this->service), true); + } + + $this->validate(); + + $upgradePlan = $this->upgradeProduct->availablePlans()->where('billing_period', $this->service->plan->billing_period)->where('billing_unit', $this->service->plan->billing_unit)->first(); + + // The old config options must be in upgradableConfigOptions + $serviceConfigOptions = $this->service->configs->pluck('config_value_id', 'config_option_id')->toArray(); + $configOptions = collect($serviceConfigOptions)->filter(function ($value, $key) use ($serviceConfigOptions) { + return isset($serviceConfigOptions[$key]) && $this->upgradeProduct->upgradableConfigOptions->contains('id', $key); + })->toArray(); + + // If the product did not change and the config options are the same, present the user with a message + if ($this->upgradeProduct->id === $this->service->product_id && $this->configOptions == $configOptions) { + $this->notify('You have not changed anything. Please select a different product or change the configuration options.', 'error'); + + return; + } + + $upgrade = new ServiceUpgrade([ + 'service_id' => $this->service->id, + 'product_id' => $this->upgradeProduct->id, + 'plan_id' => $upgradePlan->id, + ]); + $upgrade->save(); + + if ($this->configOptions) { + foreach ($this->configOptions as $optionId => $value) { + $upgrade->configs()->create([ + 'config_option_id' => $optionId, + 'config_value_id' => $value, + ]); + } + } + $price = $upgrade->calculatePrice(); + + if ($price->price <= 0) { + $upgrade->status = ServiceUpgrade::STATUS_COMPLETED; + $upgrade->save(); + + $upgrade->service()->update([ + 'plan_id' => $upgrade->plan_id, + 'product_id' => $upgrade->product_id, + ]); + + // Update the service configs + foreach ($upgrade->configs as $config) { + $upgrade->service->configs()->updateOrCreate( + ['config_option_id' => $config->config_option_id], + ['config_value_id' => $config->config_value_id] + ); + } + + $this->service->refresh(); + + $this->service->recalculatePrice(); + + if ($this->service->product->server) { + // If the service has a server, dispatch the upgrade job + UpgradeJob::dispatch($this->service); + } + + // Check if user has credits in this currency + /** @var \App\Models\User */ + $user = Auth::user(); + $credit = $user->credits()->where('currency_code', $price->currency->code)->first(); + + if ($credit) { + // Increment the credits, `abs()` ensures the amount to add is positive + $credit->increment('amount', abs($price->price)); + } else { + $user->credits()->create([ + 'currency_code' => $price->currency->code, + 'amount' => $price->price, + ]); + } + + if ($price->price < 0) { + $this->notify('The upgrade has been completed. We\'ve added the remaining amount to your account balance.', 'success'); + } else { + $this->notify('The upgrade has been completed.', 'success'); + } + + return $this->redirect(route('services.show', $this->service), true); + } + + $invoice = new Invoice([ + 'currency_code' => $this->service->currency_code, + 'status' => Invoice::STATUS_PENDING, + 'due_at' => Carbon::now()->addDays(7), + 'user_id' => $this->service->user_id, + ]); + $invoice->save(); + + $upgrade->invoice_id = $invoice->id; + $upgrade->save(); + + $invoice->items()->create([ + 'description' => 'Upgrade ' . $this->service->product->name . ' to ' . $this->upgradeProduct->name, + 'price' => $price->price, + 'quantity' => 1, + 'reference_id' => $upgrade->id, + 'reference_type' => ServiceUpgrade::class, + ]); + + event(new InvoiceCreated($invoice)); + + $this->notify('The upgrade has been added to your cart. Please complete the payment to proceed.', 'success'); + + return $this->redirect(route('invoices.show', $invoice)); + } + + public function render() + { + return view('services.upgrade')->layoutData([ + 'title' => 'Upgrade Service', + 'sidebar' => true, + ]); + } +} diff --git a/app/Livewire/Services/Widget.php b/app/Livewire/Services/Widget.php new file mode 100644 index 000000000..e70432f67 --- /dev/null +++ b/app/Livewire/Services/Widget.php @@ -0,0 +1,36 @@ +services()->orderByDesc('created_at'); + + if ($this->status) { + $query->where('status', $this->status); + } else { + $query->where('status', '!=', 'cancelled'); + } + + $services = $this->limit + ? $query->take($this->limit)->get() + : $query->paginate(config('settings.pagination')); + + return view('services.widget', [ + 'services' => $services, + ])->layoutData([ + 'title' => 'Services', + ]); + } +} diff --git a/app/Livewire/Tickets/Create.php b/app/Livewire/Tickets/Create.php new file mode 100644 index 000000000..e333590f2 --- /dev/null +++ b/app/Livewire/Tickets/Create.php @@ -0,0 +1,78 @@ +validate([ + 'department' => count((array) config('settings.ticket_departments')) > 0 ? 'required|in:' . implode(',', array_values((array) config('settings.ticket_departments'))) : '', + 'service' => 'nullable|exists:services,id', + 'subject' => 'required|string', + 'message' => 'required|string', + 'priority' => 'required|in:low,medium,high', + ]); + + if (RateLimiter::tooManyAttempts('create-ticket', 1)) { + $this->notify('Too many ticket creation attempts. Please try again in 60 seconds.', 'error'); + + return; + } + + RateLimiter::increment('create-ticket', 30); + + $ticket = Ticket::create([ + 'user_id' => Auth::id(), + 'department' => $this->department, + 'service_id' => $this->service, + 'subject' => $this->subject, + 'priority' => $this->priority, + ]); + + $ticket->messages()->create([ + 'user_id' => Auth::id(), + 'message' => $this->message, + ]); + + $this->notify('Message sent successfully'); + + $this->message = ''; + $this->dispatch('saved'); + + $this->redirect(route('tickets.show', $ticket), true); + } + + public function render() + { + /** @var \App\Models\User */ + $user = Auth::user(); + + return view('tickets.create', [ + 'departments' => (array) config('settings.ticket_departments'), + 'services' => $user->services()->orderBy('id', 'desc')->get(), + ])->layoutData([ + 'title' => 'Create Ticket', + 'sidebar' => true, + ]); + } +} diff --git a/app/Livewire/Tickets/Index.php b/app/Livewire/Tickets/Index.php new file mode 100644 index 000000000..5b509ea1c --- /dev/null +++ b/app/Livewire/Tickets/Index.php @@ -0,0 +1,25 @@ + Ticket::where('user_id', Auth::id())->latest()->paginate(config('settings.pagination')), + ])->layoutData([ + 'title' => 'Tickets', + 'sidebar' => true, + ]); + } +} diff --git a/app/Livewire/Tickets/Show.php b/app/Livewire/Tickets/Show.php new file mode 100644 index 000000000..db612bf85 --- /dev/null +++ b/app/Livewire/Tickets/Show.php @@ -0,0 +1,43 @@ +validate(); + + $this->ticket->messages()->create([ + 'user_id' => Auth::id(), + 'message' => $this->message, + ]); + + $this->notify('Message sent successfully'); + + $this->message = ''; + $this->dispatch('saved'); + } + + public function render() + { + return view('tickets.show')->layoutData([ + 'sidebar' => true, + ]); + } +} diff --git a/app/Livewire/Tickets/Widget.php b/app/Livewire/Tickets/Widget.php new file mode 100644 index 000000000..8f9bd6704 --- /dev/null +++ b/app/Livewire/Tickets/Widget.php @@ -0,0 +1,22 @@ + Ticket::where('user_id', Auth::id())->where('status', '!=', 'closed')->latest()->paginate(config('settings.pagination')), + ])->layoutData([ + 'title' => 'Tickets', + ]); + } +} diff --git a/app/Livewire/Traits/Disabled.php b/app/Livewire/Traits/Disabled.php new file mode 100644 index 000000000..bb32bc456 --- /dev/null +++ b/app/Livewire/Traits/Disabled.php @@ -0,0 +1,23 @@ +getAttributes(\App\Attributes\DisabledIf::class); + + // Check if the DisabledIf attribute is present + foreach ($attributes as $attribute) { + $instance = $attribute->newInstance(); + // Check the condition based on the attribute's setting + if (config('settings.' . $instance->setting, $instance->default)) { + // If the condition is met, abort with a 404 error + abort(404, 'This feature is currently disabled.'); + } + } + } +} diff --git a/app/Livewire/Traits/HasNotifications.php b/app/Livewire/Traits/HasNotifications.php new file mode 100644 index 000000000..a45370ab8 --- /dev/null +++ b/app/Livewire/Traits/HasNotifications.php @@ -0,0 +1,27 @@ +notify( + Session::get('notification')['message'], + Session::get('notification')['type'] + ); + Session::forget('notification'); + } + } + + /** + * Notifications + */ + public function notify($message, $type = 'success') + { + $this->dispatch('notify', ['message' => $message, 'type' => $type]); + } +} diff --git a/app/Mail/Mail.php b/app/Mail/Mail.php new file mode 100644 index 000000000..861bbe4e4 --- /dev/null +++ b/app/Mail/Mail.php @@ -0,0 +1,54 @@ +emailTemplate = $emailTemplate; + $this->data = $data; + $this->data['body'] = $this->emailTemplate->body; + } + + /** + * Get the message envelope. + */ + public function envelope(): Envelope + { + return new Envelope( + subject: $this->emailTemplate->subject, + ); + } + + /** + * Get the message content definition. + */ + public function content(): Content + { + return new Content( + html: 'components.mail.base', + with: $this->data, + ); + } +} diff --git a/app/Models/ApiKey.php b/app/Models/ApiKey.php new file mode 100644 index 000000000..d67f0138d --- /dev/null +++ b/app/Models/ApiKey.php @@ -0,0 +1,25 @@ + 'array', + 'ip_addresses' => 'array', + 'last_used_at' => 'datetime', + ]; +} diff --git a/app/Models/AuditLog.php b/app/Models/AuditLog.php new file mode 100644 index 000000000..a51c62eb1 --- /dev/null +++ b/app/Models/AuditLog.php @@ -0,0 +1,42 @@ + 'array', + ]; + + /** + * Get the model that was changed. + */ + public function model() + { + return $this->morphTo(); + } + + /** + * Get the user that performed the action. + */ + public function user() + { + return $this->belongsTo(User::class); + } +} diff --git a/app/Models/Category.php b/app/Models/Category.php new file mode 100644 index 000000000..02593d084 --- /dev/null +++ b/app/Models/Category.php @@ -0,0 +1,42 @@ +hasMany(Product::class); + } + + /** + * Get the parent category of the category. + */ + public function parent(): BelongsTo + { + return $this->belongsTo(Category::class, 'parent_id'); + } + + /** + * Get the children categories of the category. + */ + public function children(): HasMany + { + return $this->hasMany(Category::class, 'parent_id'); + } +} diff --git a/app/Models/ConfigOption.php b/app/Models/ConfigOption.php new file mode 100644 index 000000000..024394f41 --- /dev/null +++ b/app/Models/ConfigOption.php @@ -0,0 +1,56 @@ +belongsTo(ConfigOption::class, 'parent_id'); + } + + /** + * Get the options that belong to the parent. (children or options) + */ + public function children() + { + return $this->hasMany(ConfigOption::class, 'parent_id')->orderBy('sort'); + } + + /** + * Get the products that belong to the option. + */ + public function products() + { + return $this->belongsToMany(Product::class, 'config_option_products'); + } + + /** + * Get the service configs that belong to the option. + */ + public function serviceConfigs() + { + return $this->hasMany(ServiceConfig::class, 'config_option_id'); + } +} diff --git a/app/Models/ConfigOptionProduct.php b/app/Models/ConfigOptionProduct.php new file mode 100644 index 000000000..3f643743e --- /dev/null +++ b/app/Models/ConfigOptionProduct.php @@ -0,0 +1,34 @@ +belongsTo(ConfigOption::class); + } + + /** + * Get the product of the option. + */ + public function product() + { + return $this->belongsTo(Product::class); + } +} diff --git a/app/Models/Coupon.php b/app/Models/Coupon.php new file mode 100644 index 000000000..2351be30e --- /dev/null +++ b/app/Models/Coupon.php @@ -0,0 +1,59 @@ + 'datetime', + 'expires_at' => 'datetime', + 'max_uses' => 'integer', + 'max_uses_per_user' => 'integer', + ]; + + /** + * Get the products that belong to the option. + */ + public function products() + { + return $this->belongsToMany(Product::class, 'coupon_products'); + } + + public function services() + { + return $this->hasMany(Service::class); + } + + /** + * Check if the user has exceeded the maximum allowed uses of this coupon + * + * @param int $userId + */ + public function hasExceededMaxUsesPerUser($userId): bool + { + if (empty($this->max_uses_per_user)) { + return false; + } + + return $this->services() + ->where('user_id', $userId) + ->count() >= $this->max_uses_per_user; + } +} diff --git a/app/Models/CouponProduct.php b/app/Models/CouponProduct.php new file mode 100644 index 000000000..d765cf8d8 --- /dev/null +++ b/app/Models/CouponProduct.php @@ -0,0 +1,26 @@ +belongsTo(Coupon::class); + } + + public function product() + { + return $this->belongsTo(Product::class); + } +} diff --git a/app/Models/Credit.php b/app/Models/Credit.php new file mode 100644 index 000000000..c817c4625 --- /dev/null +++ b/app/Models/Credit.php @@ -0,0 +1,32 @@ +belongsTo(User::class); + } + + public function currency() + { + return $this->belongsTo(Currency::class, 'currency_code', 'code'); + } + + public function formattedAmount(): Attribute + { + return Attribute::make( + get: fn () => new Price(['price' => $this->amount, 'currency' => $this->currency]) + ); + } +} diff --git a/app/Models/Currency.php b/app/Models/Currency.php new file mode 100644 index 000000000..68d898281 --- /dev/null +++ b/app/Models/Currency.php @@ -0,0 +1,26 @@ + 'array', + ]; +} diff --git a/app/Models/DebugLog.php b/app/Models/DebugLog.php new file mode 100644 index 000000000..12aafed7b --- /dev/null +++ b/app/Models/DebugLog.php @@ -0,0 +1,17 @@ + 'json', + ]; +} diff --git a/app/Models/EmailLog.php b/app/Models/EmailLog.php new file mode 100644 index 000000000..20153aef4 --- /dev/null +++ b/app/Models/EmailLog.php @@ -0,0 +1,22 @@ + 'boolean', + 'cc' => 'array', + 'bcc' => 'array', + ]; +} diff --git a/app/Models/Extension.php b/app/Models/Extension.php new file mode 100644 index 000000000..5c7148d87 --- /dev/null +++ b/app/Models/Extension.php @@ -0,0 +1,44 @@ +morphMany(Setting::class, 'settingable'); + } + + public function path(): Attribute + { + return Attribute::make( + get: fn () => ucfirst($this->type) . 's/' . ucfirst($this->extension) + ); + } + + public function namespace(): Attribute + { + return Attribute::make( + get: fn () => 'Paymenter\\Extensions\\' . ucfirst($this->type) . 's\\' . ucfirst($this->extension) + ); + } +} diff --git a/app/Models/FailedJob.php b/app/Models/FailedJob.php new file mode 100644 index 000000000..28beeabd7 --- /dev/null +++ b/app/Models/FailedJob.php @@ -0,0 +1,36 @@ + 'datetime', + ]; + + public function retry() + { + try { + Artisan::call('queue:retry', ['id' => $this->uuid]); + } catch (\Exception $e) { + return $e->getMessage(); + } + + // $this->delete(); + } +} diff --git a/app/Models/Gateway.php b/app/Models/Gateway.php new file mode 100644 index 000000000..ff6cf7225 --- /dev/null +++ b/app/Models/Gateway.php @@ -0,0 +1,13 @@ +where('type', 'gateway'); + } +} diff --git a/app/Models/Invoice.php b/app/Models/Invoice.php new file mode 100644 index 000000000..d9f518593 --- /dev/null +++ b/app/Models/Invoice.php @@ -0,0 +1,102 @@ + 'date', + ]; + + public bool $send_create_email = true; + + /** + * Total of the invoice. + * + * @return string + */ + public function total(): Attribute + { + return Attribute::make( + get: fn () => $this->items->sum(fn ($item) => $item->price * $item->quantity) + ); + } + + /** + * Total of the invoice. + * + * @return string + */ + public function formattedTotal(): Attribute + { + return Attribute::make( + get: fn () => new Price(['price' => $this->total, 'currency' => $this->currency]) + ); + } + + /** + * Formatted remaining amount of the invoice. + */ + public function formattedRemaining(): Attribute + { + return Attribute::make( + get: fn () => new Price(['price' => $this->remaining, 'currency' => $this->currency]) + ); + } + + /** + * Remaining amount of the invoice. + */ + public function remaining(): Attribute + { + return Attribute::make( + get: fn () => $this->total - $this->transactions->sum('amount') + ); + } + + public function currency() + { + return $this->belongsTo(Currency::class, 'currency_code'); + } + + public function user() + { + return $this->belongsTo(User::class); + } + + public function items() + { + return $this->hasMany(InvoiceItem::class); + } + + public function transactions() + { + return $this->hasMany(InvoiceTransaction::class); + } + + public function pdf(): Attribute + { + return Attribute::make( + get: fn () => PDF::generateInvoice($this) + ); + } +} diff --git a/app/Models/InvoiceItem.php b/app/Models/InvoiceItem.php new file mode 100644 index 000000000..3384ed9d6 --- /dev/null +++ b/app/Models/InvoiceItem.php @@ -0,0 +1,61 @@ +belongsTo(Invoice::class); + } + + public function reference() + { + return $this->morphTo(); + } + + public function gateway() + { + return $this->belongsTo(Gateway::class); + } + + public function total() + { + return $this->price * $this->quantity; + } + + public function formattedTotal(): Attribute + { + return Attribute::make( + get: fn () => new Price(['price' => $this->total(), 'currency' => $this->invoice->currency]) + ); + } + + public function formattedPrice(): Attribute + { + return Attribute::make( + get: fn () => new Price(['price' => $this->price, 'currency' => $this->invoice->currency]) + ); + } +} diff --git a/app/Models/InvoiceTransaction.php b/app/Models/InvoiceTransaction.php new file mode 100644 index 000000000..a531e7ded --- /dev/null +++ b/app/Models/InvoiceTransaction.php @@ -0,0 +1,54 @@ +belongsTo(Invoice::class); + } + + public function gateway() + { + return $this->belongsTo(Gateway::class); + } + + /** + * Formatted remaining amount of the invoice. + */ + public function formattedFee(): Attribute + { + return Attribute::make( + get: fn () => new Price(['price' => $this->fee, 'currency' => $this->invoice->currency]) + ); + } + + /** + * Formatted remaining amount of the invoice. + */ + public function formattedAmount(): Attribute + { + return Attribute::make( + get: fn () => new Price(['price' => $this->amount, 'currency' => $this->invoice->currency]) + ); + } +} diff --git a/app/Models/OauthClient.php b/app/Models/OauthClient.php new file mode 100644 index 000000000..2b7e44ed0 --- /dev/null +++ b/app/Models/OauthClient.php @@ -0,0 +1,7 @@ +belongsTo(User::class); + } + + public function services() + { + return $this->hasMany(Service::class); + } + + /** + * Get the currency corresponding to the service. + */ + public function currency() + { + return $this->hasOne(Currency::class, 'code', 'currency_code'); + } + + /** + * Total of the order. + * + * @return float + */ + public function total(): Attribute + { + return Attribute::make( + get: fn () => $this->services->sum(fn ($service) => $service->price * $service->quantity) + ); + } + + /** + * Total of the order. + * + * @return string + */ + public function formattedTotal(): Attribute + { + return Attribute::make( + get: fn () => new Price(['price' => $this->total, 'currency' => $this->currency]) + ); + } + + /** + * Get all invoices for the order. + */ + public function invoices(): Attribute + { + // Each service has invoices (it is a hasManyThrough relationship order -> service -> invoiceItem -> invoice) + $invoicesId = $this->services->map(fn ($service) => $service->invoiceItems->map(fn ($invoiceItem) => $invoiceItem->invoice_id))->flatten(); + + return new Attribute( + get: fn () => Invoice::whereIn('id', $invoicesId) + ); + } +} diff --git a/app/Models/Plan.php b/app/Models/Plan.php new file mode 100644 index 000000000..2cffe4d98 --- /dev/null +++ b/app/Models/Plan.php @@ -0,0 +1,79 @@ +hasMany(Price::class); + } + + /** + * Get the priceable model of the plan. + */ + public function priceable() + { + return $this->morphTo(); + } + + /** + * Get the price of the plan. + */ + public function price() + { + if ($this->type === 'free') { + return new PriceClass(['currency' => Currency::find(session('currency', config('settings.default_currency')))], free: true); + } + $currency = session('currency', config('settings.default_currency')); + $price = $this->prices->where('currency_code', $currency)->first(); + + return new PriceClass((object) [ + 'price' => $price, + 'setup_fee' => $price->setup_fee, + 'currency' => $price->currency, + ]); + } + + // Time between billing periods + public function billingDuration(): Attribute + { + if ($this->type === 'free' || $this->type == 'one-time') { + return Attribute::make(get: fn () => 0); + } + $diffInDays = match ($this->billing_unit) { + 'day' => 1, + 'week' => 7, + 'month' => 30, + 'year' => 365, + }; + + return Attribute::make( + get: fn () => $diffInDays * $this->billing_period + ); + } + + public function services() + { + return $this->hasMany(Service::class); + } +} diff --git a/app/Models/Price.php b/app/Models/Price.php new file mode 100644 index 000000000..b29c3f035 --- /dev/null +++ b/app/Models/Price.php @@ -0,0 +1,32 @@ +morphTo(); + } + + public function currency(): BelongsTo + { + return $this->belongsTo(Currency::class); + } + + public function plan(): BelongsTo + { + return $this->belongsTo(Plan::class); + } +} diff --git a/app/Models/Product.php b/app/Models/Product.php new file mode 100644 index 000000000..f516ef1ff --- /dev/null +++ b/app/Models/Product.php @@ -0,0 +1,81 @@ +belongsTo(Category::class); + } + + /** + * Get the configurable options of the product. + */ + public function configOptions(): HasManyThrough + { + return $this->hasManyThrough(ConfigOption::class, ConfigOptionProduct::class, 'product_id', 'id', 'id', 'config_option_id')->where('hidden', false)->orderBy('sort', 'asc')->orderBy('id', 'desc'); + } + + /** + * Get the extension of the product. + */ + public function server() + { + return $this->belongsTo(Server::class); + } + + /** + * Get all services using this product. + */ + public function services(): HasMany + { + return $this->hasMany(Service::class); + } + + /** + * Get the settings of the product. + */ + public function settings(): MorphMany + { + return $this->morphMany(Setting::class, 'settingable'); + } + + /** + * Get all available products upgrades + */ + public function upgrades() + { + return $this->belongsToMany(Product::class, 'product_upgrades', 'product_id', 'upgrade_id'); + } + + /** + * Gets all upgradable config options for the product. + */ + public function upgradableConfigOptions(): HasManyThrough + { + return $this->hasManyThrough(ConfigOption::class, ConfigOptionProduct::class, 'product_id', 'id', 'id', 'config_option_id')->where('hidden', false)->where('upgradable', true)->orderBy('sort', 'asc')->orderBy('id', 'desc'); + } +} diff --git a/app/Models/ProductUpgrade.php b/app/Models/ProductUpgrade.php new file mode 100644 index 000000000..242492541 --- /dev/null +++ b/app/Models/ProductUpgrade.php @@ -0,0 +1,23 @@ +belongsTo(Product::class); + } + + public function upgrade() + { + return $this->belongsTo(Product::class, 'upgrade_id'); + } +} diff --git a/app/Models/Property.php b/app/Models/Property.php new file mode 100644 index 000000000..f4c6e064d --- /dev/null +++ b/app/Models/Property.php @@ -0,0 +1,26 @@ +belongsTo(CustomProperty::class, 'custom_property_id'); + } + + public function model() + { + return $this->morphTo(); + } +} diff --git a/app/Models/Role.php b/app/Models/Role.php new file mode 100644 index 000000000..66682713c --- /dev/null +++ b/app/Models/Role.php @@ -0,0 +1,35 @@ + + */ + protected $fillable = [ + 'name', + 'permissions', + ]; + + /** + * The attributes that should be cast. + * + * @var array + */ + protected $casts = [ + 'permissions' => 'array', + ]; + + public function users() + { + return $this->belongsToMany(User::class); + } +} diff --git a/app/Models/Server.php b/app/Models/Server.php new file mode 100644 index 000000000..e2a406bb2 --- /dev/null +++ b/app/Models/Server.php @@ -0,0 +1,13 @@ +where('type', 'server'); + } +} diff --git a/app/Models/Service.php b/app/Models/Service.php new file mode 100644 index 000000000..d7f9d56ac --- /dev/null +++ b/app/Models/Service.php @@ -0,0 +1,220 @@ + 'date', + ]; + + /** + * Get the order that owns the service. + */ + public function order() + { + return $this->belongsTo(Order::class); + } + + /** + * Get the coupon that owns the service. + */ + public function coupon() + { + return $this->belongsTo(Coupon::class); + } + + /** + * Get the currency corresponding to the service. + */ + public function currency() + { + return $this->hasOne(Currency::class, 'code', 'currency_code'); + } + + /** + * Get the user that owns the service. + */ + public function user() + { + return $this->belongsTo(User::class); + } + + /** + * Price of the service. + * + * @return string + */ + public function formattedPrice(): Attribute + { + return Attribute::make( + get: fn () => new Price(['price' => $this->price * $this->quantity, 'currency' => $this->currency]) + ); + } + + /** + * Get the description for the next invoice item. + */ + public function description(): Attribute + { + if ($this->plan->type == 'free' || $this->plan->type == 'one-time') { + return Attribute::make( + get: fn () => $this->product->name + ); + } + $date = $this->expires_at ?? now(); + $endDate = $date->copy()->{'add' . ucfirst($this->plan->billing_unit) . 's'}($this->plan->billing_period); + + return Attribute::make( + get: fn () => $this->product->name . ' (' . $date->format('M d, Y') . ' - ' . $endDate->format('M d, Y') . ')' + ); + } + + /** + * Calculate next due date. + */ + public function calculateNextDueDate() + { + if ($this->plan->type == 'one-time' || $this->plan->type == 'free') { + return null; + } + $date = $this->expires_at ?? now(); + + return $date->{'add' . ucfirst($this->plan->billing_unit) . 's'}($this->plan->billing_period); + } + + /** + * Get the product corresponding to the service. + */ + public function product() + { + return $this->belongsTo(Product::class); + } + + /** + * Get the plan corresponding to the service. + */ + public function plan() + { + return $this->belongsTo(Plan::class); + } + + public function proxmoxServer(): HasOne + { + return $this->hasOne(ProxmoxServer::class); + } + + /** + * Get the service's configurations. + */ + public function configs() + { + return $this->morphMany(ServiceConfig::class, 'configurable'); + } + + /** + * Get invoiceItems + */ + public function invoiceItems() + { + return $this->morphMany(InvoiceItem::class, 'reference'); + } + + /** + * Get invoices + */ + public function invoices() + { + return $this->hasManyThrough(Invoice::class, InvoiceItem::class, 'reference_id', 'id', 'id', 'invoice_id')->where('reference_type', Service::class); + } + + /** + * Get cancellation requests + */ + public function cancellation() + { + return $this->hasOne(ServiceCancellation::class); + } + + public function cancellable(): Attribute + { + return Attribute::make( + get: fn () => $this->status !== 'cancelled' && $this->plan->type != 'free' && $this->plan->type != 'one-time' && !$this->cancellation?->exists() + ); + } + + public function upgradable(): Attribute + { + return Attribute::make( + get: fn () => ($this->productUpgrades()->count() > 0 || $this->product->upgradableConfigOptions()->count() > 0) && $this->status == 'active' && $this->upgrade->where('status', ServiceUpgrade::STATUS_PENDING)->count() == 0); + } + + public function productUpgrades() + { + return $this->product->upgrades->filter(function ($product) { + $plan = $product->plans()->where('billing_unit', $this->plan->billing_unit)->where('billing_period', $this->plan->billing_period)->get(); + // Only get the upgrades that have the exact same billing cycle as the service + if ($plan->count() > 0) { + $product->plan = $plan->first(); + + return $product; + } + + return null; + }); + } + + public function recalculatePrice() + { + // Calculate the price based on the plan and quantity and config options + $price = $this->plan->price()->price * $this->quantity; + $this->configs->each(function ($config) use (&$price) { + $configValue = $config->configValue; + if ($configValue) { + $price += $configValue->price(null, $this->plan->billing_period, $this->plan->billing_unit, $this->currency_code)->price; + } + }); + $this->price = $price; + $this->save(); + } + + public function upgrade() + { + return $this->hasMany(ServiceUpgrade::class); + } +} diff --git a/app/Models/ServiceCancellation.php b/app/Models/ServiceCancellation.php new file mode 100644 index 000000000..e72576b51 --- /dev/null +++ b/app/Models/ServiceCancellation.php @@ -0,0 +1,25 @@ +belongsTo(Service::class); + } +} diff --git a/app/Models/ServiceConfig.php b/app/Models/ServiceConfig.php new file mode 100644 index 000000000..8e4cc2890 --- /dev/null +++ b/app/Models/ServiceConfig.php @@ -0,0 +1,41 @@ +belongsTo(Service::class); + } + + /** + * Get the config option that owns the service config. + */ + public function configOption() + { + return $this->belongsTo(ConfigOption::class); + } + + /** + * Get the config value that owns the service config. + */ + public function configValue() + { + return $this->belongsTo(ConfigOption::class, 'config_value_id'); + } +} diff --git a/app/Models/ServiceUpgrade.php b/app/Models/ServiceUpgrade.php new file mode 100644 index 000000000..c3072a3b6 --- /dev/null +++ b/app/Models/ServiceUpgrade.php @@ -0,0 +1,108 @@ +belongsTo(Service::class); + } + + public function product() + { + return $this->belongsTo(Product::class); + } + + public function plan() + { + return $this->belongsTo(Plan::class); + } + + public function invoice() + { + return $this->belongsTo(Invoice::class); + } + + public function configs() + { + return $this->morphMany(ServiceConfig::class, 'configurable'); + } + + public function calculateProratedAmount($oldItem, $newItem): Price + { + // Calculate the total number of days in the billing period + $billingPeriodDays = match ($this->service->plan->billing_unit) { + 'day' => $this->service->plan->billing_period, + 'week' => $this->service->plan->billing_period * 7, + 'month' => $this->service->plan->billing_period * 30, + 'year' => $this->service->plan->billing_period * 365, + default => 0, + }; + + // Calculate the remaining days until the service expires + $expiresAt = $this->service->expires_at->copy()->startOfDay(); + $now = Carbon::now()->startOfDay(); + $remainingDays = $expiresAt->diffInDays($now, true); + $remainingDays = min($remainingDays, $billingPeriodDays); + + // Calculate the prorated amount + $newPrice = $newItem->price(null, $this->service->plan->billing_period, $this->service->plan->billing_unit, $this->service->currency_code); + if (empty($oldItem)) { + $oldPrice = 0; + } else { + $oldPrice = $oldItem->price(null, $this->service->plan->billing_period, $this->service->plan->billing_unit, $this->service->currency_code)->price; + } + $priceDifference = $newPrice->price - $oldPrice; + $total = ($priceDifference / $billingPeriodDays) * $remainingDays; + + return new Price([ + 'price' => $total, + 'currency' => $this->service->currency, + ]); + } + + public function calculatePrice(): Price + { + $total = $this->calculateProratedAmount( + $this->service->product, + $this->product + )->price; + + foreach ($this->configs as $config) { + $configValue = $config->configValue; + if (!$configValue) { + continue; + } + + $oldPrice = $this->service->configs->where('config_option_id', $config->config_option_id)->first(); + + $ctotal = $this->calculateProratedAmount( + $oldPrice ? $oldPrice->configValue : null, + $configValue + ); + $total += $ctotal->price; + } + + return new Price([ + 'price' => $total, + 'currency' => $currency ?? $this->service->currency, + ]); + } +} diff --git a/app/Models/Session.php b/app/Models/Session.php new file mode 100644 index 000000000..6457a75b3 --- /dev/null +++ b/app/Models/Session.php @@ -0,0 +1,109 @@ + + */ + protected function casts(): array + { + return [ + 'last_activity' => 'datetime', + ]; + } + + public function impersonating(): bool + { + try { + $payload = $this->payload; + + $decoded = config('session.encrypt') + ? Crypt::decryptString($payload) + : base64_decode($payload); + + $data = unserialize($decoded); + + return !empty($data['impersonating']); + } catch (\Throwable $e) { + return false; + } + } + + public function user() + { + return $this->belongsTo(User::class); + } + + public function getIsCurrentDeviceAttribute() + { + return $this->id === FacadesSession::getId(); + } + + public function getFormattedLastActiveAttribute() + { + return $this->last_activity->diffForHumans(); + } + + public function getIsMobileAttribute() + { + return preg_match('/(android|webos|avantgo|blackberry|bolt|boost|cricket|docomo|fone|hiptop|mini|mobi|palm|phone|pie|tablet|up\.browser|up\.link|webos|wos)/i', $this->user_agent); + } + + public function getFormattedDeviceAttribute() + { + if (preg_match('/Linux/i', $this->user_agent)) { + $os = 'Linux'; + } elseif (preg_match('/Mac/i', $this->user_agent)) { + $os = 'Mac'; + } elseif (preg_match('/iPhone/i', $this->user_agent)) { + $os = 'iPhone'; + } elseif (preg_match('/iPad/i', $this->user_agent)) { + $os = 'iPad'; + } elseif (preg_match('/Droid/i', $this->user_agent)) { + $os = 'Droid'; + } elseif (preg_match('/Unix/i', $this->user_agent)) { + $os = 'Unix'; + } elseif (preg_match('/Windows/i', $this->user_agent)) { + $os = 'Windows'; + } else { + $os = 'Unknown'; + } + + // Browser Detection + + if (preg_match('/Firefox/i', $this->user_agent)) { + $br = 'Firefox'; + } elseif (preg_match('/Mac/i', $this->user_agent)) { + $br = 'Mac'; + } elseif (preg_match('/Chrome/i', $this->user_agent)) { + $br = 'Chrome'; + } elseif (preg_match('/Opera/i', $this->user_agent)) { + $br = 'Opera'; + } elseif (preg_match('/MSIE/i', $this->user_agent)) { + $br = 'IE'; + } else { + $br = 'Unknown'; + } + + return "{$os} - {$br}"; + } +} diff --git a/app/Models/Setting.php b/app/Models/Setting.php new file mode 100644 index 000000000..f8108c514 --- /dev/null +++ b/app/Models/Setting.php @@ -0,0 +1,32 @@ + Retrieved::class, + 'saving' => Saving::class, + 'saved' => Saved::class, + ]; +} diff --git a/app/Models/TaxRate.php b/app/Models/TaxRate.php new file mode 100644 index 000000000..fd827a736 --- /dev/null +++ b/app/Models/TaxRate.php @@ -0,0 +1,13 @@ +belongsTo(User::class); + } + + public function assignedTo() + { + return $this->belongsTo(User::class, 'assigned_to'); + } + + public function service() + { + return $this->belongsTo(Service::class); + } + + public function messages() + { + return $this->hasMany(TicketMessage::class); + } +} diff --git a/app/Models/TicketMessage.php b/app/Models/TicketMessage.php new file mode 100644 index 000000000..1801322da --- /dev/null +++ b/app/Models/TicketMessage.php @@ -0,0 +1,30 @@ +belongsTo(Ticket::class); + } + + public function user() + { + return $this->belongsTo(User::class); + } +} diff --git a/app/Models/Traits/HasPlans.php b/app/Models/Traits/HasPlans.php new file mode 100644 index 000000000..a0211dc00 --- /dev/null +++ b/app/Models/Traits/HasPlans.php @@ -0,0 +1,79 @@ +morphMany(Plan::class, 'priceable')->orderBy('sort'); + } + + /** + * Get available plans of the product. + */ + public function availablePlans($currency = null) + { + $currency = $currency ?? session('currency', config('settings.default_currency')); + + return $this->plans->filter(function ($plan) use ($currency) { + if ($plan->type === 'free') { + return true; + } + + return $plan->prices->when($currency, function ($query) use ($currency) { + // Or where plan is free + return $query->where('currency_code', $currency); + })->isNotEmpty(); + }); + } + + /** + * Get first price of the plan. + */ + public function price($plan_id = null, $billing_period = null, $billing_unit = null, $currency = null) + { + $priceAndCurrency = [ + 'price' => null, + 'currency' => null, + ]; + + // Check for free plan + if ($this->availablePlans()->where('type', 'free')->isNotEmpty()) { + return new Price(free: true, dontShowUnavailablePrice: $this->dontShowUnavailablePrice ?? false); + } + + // If plan_id is not provided, and billing_period is provided, get the first plan with the billing period and time interval + if (!$plan_id && $billing_period && $billing_unit) { + $plan = $this->availablePlans()->where('billing_period', $billing_period)->where('billing_unit', $billing_unit)->first(); + $plan_id = $plan->id ?? null; + } + + $currency = $currency ?? session('currency', config('settings.default_currency')); + + foreach ($this->availablePlans()->when($plan_id, function ($query) use ($plan_id) { + return $query->where('id', $plan_id); + }) as $plan) { + foreach ($plan->prices->when($currency, function ($query) use ($currency) { + return $query->where('currency_code', $currency); + }) as $price) { + if ($price->price < $priceAndCurrency['price'] || $priceAndCurrency['price'] === null) { + $priceAndCurrency['price'] = $price; + $priceAndCurrency['currency'] = $price->currency; + } + } + + if ($priceAndCurrency['price']) { + break; + } + } + + return new Price($priceAndCurrency, dontShowUnavailablePrice: $this->dontShowUnavailablePrice ?? false); + } +} diff --git a/app/Models/Traits/HasProperties.php b/app/Models/Traits/HasProperties.php new file mode 100644 index 000000000..5edef47aa --- /dev/null +++ b/app/Models/Traits/HasProperties.php @@ -0,0 +1,16 @@ +morphMany(Property::class, 'model'); + } +} diff --git a/app/Models/Traits/Settingable.php b/app/Models/Traits/Settingable.php new file mode 100644 index 000000000..dc2ea43f4 --- /dev/null +++ b/app/Models/Traits/Settingable.php @@ -0,0 +1,8 @@ + + */ + protected $fillable = [ + 'first_name', + 'last_name', + 'email', + 'password', + 'role_id', + 'tfa_secret', + 'email_verified_at', + ]; + + /** + * The attributes that should be hidden for serialization. + * + * @var array + */ + protected $hidden = [ + 'password', + 'remember_token', + 'tfa_secret', + ]; + + /** + * Get the attributes that should be cast. + * + * @return array + */ + protected function casts(): array + { + return [ + 'email_verified_at' => 'datetime', + 'password' => 'hashed', + 'tfa_secret' => 'encrypted', + ]; + } + + /** + * Initials of the user. + * + * @return string + */ + public function initials(): Attribute + { + return Attribute::make( + get: fn () => strtoupper(substr($this->first_name, 0, 1) . substr($this->last_name, 0, 1)), + ); + } + + /** + * Avatar URL for the user. + * + * @return string + */ + public function avatar(): Attribute + { + return Attribute::make( + get: fn () => 'https://www.gravatar.com/avatar/' . md5(strtolower($this->email)) . '?d=' . urlencode((string) config('settings.gravatar_default')), + ); + } + + public function getFilamentAvatarUrl(): ?string + { + return $this->avatar; + } + + /** + * Get the display name for the user. + * + * @return string + */ + public function name(): Attribute + { + return Attribute::make( + get: fn () => ($this->first_name . ' ' . $this->last_name) ?: $this->email, + ); + } + + public function hasPermission($permission): bool + { + if (is_null($this->role)) { + return false; + } + + // If the user has all permissions, return true + if (in_array('*', $this->role->permissions)) { + return true; + } + + return in_array($permission, $this->role->permissions); + } + + /** Relationships */ + /** + * Get the role that the user belongs to. + * Can be null if the user is a normal user (non-admin). + */ + public function role() + { + return $this->belongsTo(Role::class); + } + + /** + * Get the user's sessions. + */ + public function sessions() + { + return $this->hasMany(Session::class); + } + + /** + * Get the user's orders. + */ + public function orders() + { + return $this->hasMany(Order::class); + } + + /** + * Get the user's services + */ + public function services() + { + return $this->hasMany(Service::class); + } + + /** + * Get the user's audit logs. + */ + public function audits() + { + return $this->morphMany(AuditLog::class, 'model'); + } + + /** + * Get the user's invoices. + */ + public function invoices() + { + return $this->hasMany(Invoice::class); + } + + public function canAccessPanel(Panel $panel): bool + { + if ($panel->getId() === 'admin') { + return !is_null($this->role); + } + + return false; + } + + /** + * Get the user tickets + */ + public function tickets() + { + return $this->hasMany(Ticket::class); + } + + /** + * Get the user's credits + */ + public function credits() + { + return $this->hasMany(Credit::class); + } +} diff --git a/app/Observers/CategoryObserver.php b/app/Observers/CategoryObserver.php new file mode 100644 index 000000000..b65b85875 --- /dev/null +++ b/app/Observers/CategoryObserver.php @@ -0,0 +1,56 @@ +parent; + $full_slug = $category->slug; + // Set full_slug + while ($parent) { + $fullSlug = $parent->slug; + $full_slug = $fullSlug . '/' . $full_slug; + $parent = $parent->parent; + } + + $category->full_slug = $full_slug; + } + + /** + * Handle the Category "updating" event. + * + * @return void + */ + public function updating(Category $category) + { + // Did parent or slug change? + if ($category->isDirty('parent_id') || $category->isDirty('slug')) { + $parent = $category->parent; + $full_slug = $category->slug; + // Set full_slug + while ($parent) { + $fullSlug = $parent->slug; + $full_slug = $fullSlug . '/' . $full_slug; + $parent = $parent->parent; + } + + $category->full_slug = $full_slug; + + // Update children + $category->children->each(function ($child) use ($category) { + $child->update([ + 'full_slug' => $category->full_slug . '/' . $child->slug, + ]); + }); + } + } +} diff --git a/app/Observers/InvoiceItemObserver.php b/app/Observers/InvoiceItemObserver.php new file mode 100644 index 000000000..ac17cacd6 --- /dev/null +++ b/app/Observers/InvoiceItemObserver.php @@ -0,0 +1,49 @@ +send_create_email; + + dispatch(function () use ($invoice, $sendEmail) { + event(new InvoiceEvent\Finalized($invoice, $sendEmail)); + })->afterResponse(); + } + + /** + * Handle the Invoice "updating" event. + */ + public function updating(Invoice $invoice): void + { + event(new InvoiceEvent\Updating($invoice)); + } + + /** + * Handle the Invoice "updated" event. + */ + public function updated(Invoice $invoice): void + { + if ($invoice->isDirty('status') && $invoice->status == 'paid') { + event(new InvoiceEvent\Paid($invoice)); + } + event(new InvoiceEvent\Updated($invoice)); + } + + /** + * Handle the Invoice "deleted" event. + */ + public function deleted(Invoice $invoice): void + { + event(new InvoiceEvent\Deleted($invoice)); + } +} diff --git a/app/Observers/InvoiceTransactionObserver.php b/app/Observers/InvoiceTransactionObserver.php new file mode 100644 index 000000000..136adeaf0 --- /dev/null +++ b/app/Observers/InvoiceTransactionObserver.php @@ -0,0 +1,49 @@ +send_create_email; + + dispatch(function () use ($order, $sendEmail) { + event(new OrderEvent\Finalized($order, $sendEmail)); + })->afterResponse(); + } + + /** + * Handle the Order "updating" event. + */ + public function updating(Order $order): void + { + event(new OrderEvent\Updating($order)); + } + + /** + * Handle the Order "updated" event. + */ + public function updated(Order $order): void + { + event(new OrderEvent\Updated($order)); + } + + /** + * Handle the Order "deleted" event. + */ + public function deleted(Order $order): void + { + event(new OrderEvent\Deleted($order)); + } +} diff --git a/app/Observers/PropertyObserver.php b/app/Observers/PropertyObserver.php new file mode 100644 index 000000000..53678d1ea --- /dev/null +++ b/app/Observers/PropertyObserver.php @@ -0,0 +1,33 @@ +hasPermission('admin.api_keys.viewAny'); + } + + /** + * Determine whether the user can view the model. + */ + public function view(User $user, ApiKey $api_key): bool + { + return $user->hasPermission('admin.api_keys.view'); + } + + /** + * Determine whether the user can create models. + */ + public function create(User $user): bool + { + return $user->hasPermission('admin.api_keys.create'); + } + + /** + * Determine whether the user can update the model. + */ + public function update(User $user, ApiKey $api_key): bool + { + return $user->hasPermission('admin.api_keys.update'); + } + + /** + * Determine whether the user can delete the model. + */ + public function delete(User $user, ApiKey $api_key): bool + { + return $user->hasPermission('admin.api_keys.delete'); + } + + /** + * Determine whether the user can permanently delete the model. + */ + public function deleteAny(User $user): bool + { + return $user->hasPermission('admin.api_keys.deleteAny'); + } +} diff --git a/app/Policies/BasePolicy.php b/app/Policies/BasePolicy.php new file mode 100644 index 000000000..28db55cdc --- /dev/null +++ b/app/Policies/BasePolicy.php @@ -0,0 +1,14 @@ +is('admin/*') || request()->routeIs('paymenter.livewire.update')) && $user->hasPermission($permission); + } +} diff --git a/app/Policies/CategoryPolicy.php b/app/Policies/CategoryPolicy.php new file mode 100644 index 000000000..e14e20d09 --- /dev/null +++ b/app/Policies/CategoryPolicy.php @@ -0,0 +1,57 @@ +hasPermission('admin.categories.viewAny'); + } + + /** + * Determine whether the user can view the model. + */ + public function view(User $user, Category $category): bool + { + return $user->hasPermission('admin.categories.view'); + } + + /** + * Determine whether the user can create models. + */ + public function create(User $user): bool + { + return $user->hasPermission('admin.categories.create'); + } + + /** + * Determine whether the user can update the model. + */ + public function update(User $user, Category $category): bool + { + return $user->hasPermission('admin.categories.update'); + } + + /** + * Determine whether the user can delete the model. + */ + public function delete(User $user, Category $category): bool + { + return $user->hasPermission('admin.categories.delete'); + } + + /** + * Determine whether the user can bulk delete the model. + */ + public function deleteAny(User $user): bool + { + return $user->hasPermission('admin.categories.deleteAny'); + } +} diff --git a/app/Policies/ConfigOptionPolicy.php b/app/Policies/ConfigOptionPolicy.php new file mode 100644 index 000000000..b6553e9b3 --- /dev/null +++ b/app/Policies/ConfigOptionPolicy.php @@ -0,0 +1,57 @@ +hasPermission('admin.config_options.viewAny'); + } + + /** + * Determine whether the user can view the model. + */ + public function view(User $user, ConfigOption $configOption): bool + { + return $user->hasPermission('admin.config_options.view'); + } + + /** + * Determine whether the user can create models. + */ + public function create(User $user): bool + { + return $user->hasPermission('admin.config_options.create'); + } + + /** + * Determine whether the user can update the model. + */ + public function update(User $user, ConfigOption $configOption): bool + { + return $user->hasPermission('admin.config_options.update'); + } + + /** + * Determine whether the user can delete the model. + */ + public function delete(User $user, ConfigOption $configOption): bool + { + return $user->hasPermission('admin.config_options.delete'); + } + + /** + * Determine whether the user can delete multiple models. + */ + public function deleteAny(User $user): bool + { + return $user->hasPermission('admin.config_options.deleteAny'); + } +} diff --git a/app/Policies/CouponPolicy.php b/app/Policies/CouponPolicy.php new file mode 100644 index 000000000..5276998d6 --- /dev/null +++ b/app/Policies/CouponPolicy.php @@ -0,0 +1,57 @@ +hasPermission('admin.coupons.viewAny'); + } + + /** + * Determine whether the user can view the model. + */ + public function view(User $user, Coupon $coupon): bool + { + return $user->hasPermission('admin.coupons.view'); + } + + /** + * Determine whether the user can create models. + */ + public function create(User $user): bool + { + return $user->hasPermission('admin.coupons.create'); + } + + /** + * Determine whether the user can update the model. + */ + public function update(User $user, Coupon $coupon): bool + { + return $user->hasPermission('admin.coupons.update'); + } + + /** + * Determine whether the user can delete the model. + */ + public function delete(User $user, Coupon $coupon): bool + { + return $user->hasPermission('admin.coupons.delete'); + } + + /** + * Determine whether the user can delete multiple models. + */ + public function deleteAny(User $user): bool + { + return $user->hasPermission('admin.coupons.deleteAny'); + } +} diff --git a/app/Policies/CurrencyPolicy.php b/app/Policies/CurrencyPolicy.php new file mode 100644 index 000000000..b9bcc80cd --- /dev/null +++ b/app/Policies/CurrencyPolicy.php @@ -0,0 +1,57 @@ +hasPermission('admin.currencies.viewAny'); + } + + /** + * Determine whether the user can view the model. + */ + public function view(User $user, Currency $currency): bool + { + return $user->hasPermission('admin.currencies.view'); + } + + /** + * Determine whether the user can create models. + */ + public function create(User $user): bool + { + return $user->hasPermission('admin.currencies.create'); + } + + /** + * Determine whether the user can update the model. + */ + public function update(User $user, Currency $currency): bool + { + return $user->hasPermission('admin.currencies.update'); + } + + /** + * Determine whether the user can delete the model. + */ + public function delete(User $user, Currency $currency): bool + { + return $user->hasPermission('admin.currencies.delete'); + } + + /** + * Determine whether the user can delete any models. + */ + public function deleteAny(User $user): bool + { + return $user->hasPermission('admin.currencies.deleteAny'); + } +} diff --git a/app/Policies/CustomPropertyPolicy.php b/app/Policies/CustomPropertyPolicy.php new file mode 100644 index 000000000..f0c0f7816 --- /dev/null +++ b/app/Policies/CustomPropertyPolicy.php @@ -0,0 +1,57 @@ +hasPermission('admin.custom_properties.viewAny'); + } + + /** + * Determine whether the user can view the model. + */ + public function view(User $user, CustomProperty $customProperty): bool + { + return $user->hasPermission('admin.custom_properties.view'); + } + + /** + * Determine whether the user can create models. + */ + public function create(User $user): bool + { + return $user->hasPermission('admin.custom_properties.create'); + } + + /** + * Determine whether the user can update the model. + */ + public function update(User $user, CustomProperty $customProperty): bool + { + return $user->hasPermission('admin.custom_properties.update'); + } + + /** + * Determine whether the user can delete the model. + */ + public function delete(User $user, CustomProperty $customProperty): bool + { + return $user->hasPermission('admin.custom_properties.delete'); + } + + /** + * Determine whether the user can bulk delete the model. + */ + public function deleteAny(User $user): bool + { + return $user->hasPermission('admin.custom_properties.deleteAny'); + } +} diff --git a/app/Policies/EmailLogPolicy.php b/app/Policies/EmailLogPolicy.php new file mode 100644 index 000000000..3424ceb54 --- /dev/null +++ b/app/Policies/EmailLogPolicy.php @@ -0,0 +1,24 @@ +hasPermission('admin.email_logs.viewAny'); + } + + /** + * Determine whether the user can view the model. + */ + public function view(User $user): bool + { + return $user->hasPermission('admin.email_logs.view'); + } +} diff --git a/app/Policies/EmailTemplatePolicy.php b/app/Policies/EmailTemplatePolicy.php new file mode 100644 index 000000000..555f94641 --- /dev/null +++ b/app/Policies/EmailTemplatePolicy.php @@ -0,0 +1,57 @@ +hasPermission('admin.email_templates.viewAny'); + } + + /** + * Determine whether the user can view the model. + */ + public function view(User $user, EmailTemplate $emailTemplate): bool + { + return $user->hasPermission('admin.email_templates.view'); + } + + /** + * Determine whether the user can create models. + */ + public function create(User $user): bool + { + return $user->hasPermission('admin.email_templates.create'); + } + + /** + * Determine whether the user can update the model. + */ + public function update(User $user, EmailTemplate $emailTemplate): bool + { + return $user->hasPermission('admin.email_templates.update'); + } + + /** + * Determine whether the user can delete the model. + */ + public function delete(User $user, EmailTemplate $emailTemplate): bool + { + return $user->hasPermission('admin.email_templates.delete'); + } + + /** + * Determine whether the user can bulk delete the model. + */ + public function deleteAny(User $user): bool + { + return $user->hasPermission('admin.email_templates.deleteAny'); + } +} diff --git a/app/Policies/ExtensionPolicy.php b/app/Policies/ExtensionPolicy.php new file mode 100644 index 000000000..039be9de3 --- /dev/null +++ b/app/Policies/ExtensionPolicy.php @@ -0,0 +1,33 @@ +hasPermission('admin.extensions.viewAny'); + } + + /** + * Determine whether the user can view the model. + */ + public function view(User $user): bool + { + return $user->hasPermission('admin.extensions.view'); + } + + /** + * Determine whether the user can update the model. + */ + public function update(User $user, Extension $extension): bool + { + return $user->hasPermission('admin.extensions.update'); + } +} diff --git a/app/Policies/FailedJobPolicy.php b/app/Policies/FailedJobPolicy.php new file mode 100644 index 000000000..1f27e9889 --- /dev/null +++ b/app/Policies/FailedJobPolicy.php @@ -0,0 +1,16 @@ +hasPermission('admin.failed_jobs.viewAny'); + } +} diff --git a/app/Policies/GatewayPolicy.php b/app/Policies/GatewayPolicy.php new file mode 100644 index 000000000..23ca2587c --- /dev/null +++ b/app/Policies/GatewayPolicy.php @@ -0,0 +1,57 @@ +hasPermission('admin.gateways.viewAny'); + } + + /** + * Determine whether the user can view the model. + */ + public function view(User $user, Gateway $gateway): bool + { + return $user->hasPermission('admin.gateways.view'); + } + + /** + * Determine whether the user can create models. + */ + public function create(User $user): bool + { + return $user->hasPermission('admin.gateways.create'); + } + + /** + * Determine whether the user can update the model. + */ + public function update(User $user, Gateway $gateway): bool + { + return $user->hasPermission('admin.gateways.update'); + } + + /** + * Determine whether the user can delete the model. + */ + public function delete(User $user, Gateway $gateway): bool + { + return $user->hasPermission('admin.gateways.delete'); + } + + /** + * Determine whether the user can delete multiple models. + */ + public function deleteAny(User $user): bool + { + return $user->hasPermission('admin.gateways.deleteAny'); + } +} diff --git a/app/Policies/InvoicePolicy.php b/app/Policies/InvoicePolicy.php new file mode 100644 index 000000000..3b52315e5 --- /dev/null +++ b/app/Policies/InvoicePolicy.php @@ -0,0 +1,57 @@ +hasPermission('admin.invoices.viewAny'); + } + + /** + * Determine whether the user can view the model. + */ + public function view(User $user, Invoice $invoice): bool + { + return $this->adminPermission($user, 'admin.invoices.view') || $invoice->user_id === $user->id; + } + + /** + * Determine whether the user can create models. + */ + public function create(User $user): bool + { + return $user->hasPermission('admin.invoices.create'); + } + + /** + * Determine whether the user can update the model. + */ + public function update(User $user, Invoice $invoice): bool + { + return $this->adminPermission($user, 'admin.invoices.update') || $invoice->user_id === $user->id; + } + + /** + * Determine whether the user can delete the model. + */ + public function delete(User $user, Invoice $model): bool + { + return $user->hasPermission('admin.invoices.delete'); + } + + /** + * Determine whether the user can delete the model. + */ + public function deleteAny(User $user): bool + { + return $user->hasPermission('admin.invoices.deleteAny'); + } +} diff --git a/app/Policies/OauthClientPolicy.php b/app/Policies/OauthClientPolicy.php new file mode 100644 index 000000000..e36b05480 --- /dev/null +++ b/app/Policies/OauthClientPolicy.php @@ -0,0 +1,57 @@ +hasPermission('admin.oauth_clients.viewAny'); + } + + /** + * Determine whether the user can view the model. + */ + public function view(User $user, OauthClient $oauthClient): bool + { + return $user->hasPermission('admin.oauth_clients.view'); + } + + /** + * Determine whether the user can create models. + */ + public function create(User $user): bool + { + return $user->hasPermission('admin.oauth_clients.create'); + } + + /** + * Determine whether the user can update the model. + */ + public function update(User $user, OauthClient $oauthClient): bool + { + return $user->hasPermission('admin.oauth_clients.update'); + } + + /** + * Determine whether the user can delete the model. + */ + public function delete(User $user, OauthClient $oauthClient): bool + { + return $user->hasPermission('admin.oauth_clients.delete'); + } + + /** + * Determine whether the user can bulk delete the model. + */ + public function deleteAny(User $user): bool + { + return $user->hasPermission('admin.oauth_clients.deleteAny'); + } +} diff --git a/app/Policies/OrderPolicy.php b/app/Policies/OrderPolicy.php new file mode 100644 index 000000000..efbdabd94 --- /dev/null +++ b/app/Policies/OrderPolicy.php @@ -0,0 +1,57 @@ +hasPermission('admin.orders.viewAny'); + } + + /** + * Determine whether the user can view the model. + */ + public function view(User $user, Order $order): bool + { + return $user->hasPermission('admin.orders.view'); + } + + /** + * Determine whether the user can create models. + */ + public function create(User $user): bool + { + return $user->hasPermission('admin.orders.create'); + } + + /** + * Determine whether the user can update the model. + */ + public function update(User $user, Order $order): bool + { + return $user->hasPermission('admin.orders.update'); + } + + /** + * Determine whether the user can delete the model. + */ + public function delete(User $user, Order $order): bool + { + return $user->hasPermission('admin.orders.delete'); + } + + /** + * Determine whether the user can permanently delete the model. + */ + public function deleteAny(User $user): bool + { + return $user->hasPermission('admin.orders.deleteAny'); + } +} diff --git a/app/Policies/ProductPolicy.php b/app/Policies/ProductPolicy.php new file mode 100644 index 000000000..13e88acd6 --- /dev/null +++ b/app/Policies/ProductPolicy.php @@ -0,0 +1,57 @@ +hasPermission('admin.products.viewAny'); + } + + /** + * Determine whether the user can view the model. + */ + public function view(User $user, Product $product): bool + { + return $user->hasPermission('admin.products.view'); + } + + /** + * Determine whether the user can create models. + */ + public function create(User $user): bool + { + return $user->hasPermission('admin.products.create'); + } + + /** + * Determine whether the user can update the model. + */ + public function update(User $user, Product $product): bool + { + return $user->hasPermission('admin.products.update'); + } + + /** + * Determine whether the user can delete the model. + */ + public function delete(User $user, Product $product): bool + { + return $user->hasPermission('admin.products.delete'); + } + + /** + * Determine whether the user can delete multiple models. + */ + public function deleteAny(User $user): bool + { + return $user->hasPermission('admin.products.deleteAny'); + } +} diff --git a/app/Policies/RolePolicy.php b/app/Policies/RolePolicy.php new file mode 100644 index 000000000..56680b3a0 --- /dev/null +++ b/app/Policies/RolePolicy.php @@ -0,0 +1,57 @@ +hasPermission('admin.roles.viewAny'); + } + + /** + * Determine whether the user can view the model. + */ + public function view(User $user, Role $role): bool + { + return $user->hasPermission('admin.roles.view'); + } + + /** + * Determine whether the user can create models. + */ + public function create(User $user): bool + { + return $user->hasPermission('admin.roles.create'); + } + + /** + * Determine whether the user can update the model. + */ + public function update(User $user, Role $role): bool + { + return $user->hasPermission('admin.roles.update'); + } + + /** + * Determine whether the user can delete the model. + */ + public function delete(User $user, Role $role): bool + { + return $user->hasPermission('admin.roles.delete'); + } + + /** + * Determine whether the user can delete multiple models. + */ + public function deleteAny(User $user): bool + { + return $user->hasPermission('admin.roles.deleteAny'); + } +} diff --git a/app/Policies/ServerPolicy.php b/app/Policies/ServerPolicy.php new file mode 100644 index 000000000..0141d5e98 --- /dev/null +++ b/app/Policies/ServerPolicy.php @@ -0,0 +1,57 @@ +hasPermission('admin.servers.viewAny'); + } + + /** + * Determine whether the user can view the model. + */ + public function view(User $user, Server $server): bool + { + return $user->hasPermission('admin.servers.view'); + } + + /** + * Determine whether the user can create models. + */ + public function create(User $user): bool + { + return $user->hasPermission('admin.servers.create'); + } + + /** + * Determine whether the user can update the model. + */ + public function update(User $user, Server $server): bool + { + return $user->hasPermission('admin.servers.update'); + } + + /** + * Determine whether the user can delete the model. + */ + public function delete(User $user, Server $server): bool + { + return $user->hasPermission('admin.servers.delete'); + } + + /** + * Determine whether the user can delete any models. + */ + public function deleteAny(User $user): bool + { + return $user->hasPermission('admin.servers.deleteAny'); + } +} diff --git a/app/Policies/ServiceCancellationPolicy.php b/app/Policies/ServiceCancellationPolicy.php new file mode 100644 index 000000000..1107b11fb --- /dev/null +++ b/app/Policies/ServiceCancellationPolicy.php @@ -0,0 +1,57 @@ +hasPermission('admin.service_cancellations.viewAny'); + } + + /** + * Determine whether the user can view the model. + */ + public function view(User $user, ServiceCancellation $category): bool + { + return $user->hasPermission('admin.service_cancellations.view'); + } + + /** + * Determine whether the user can create models. + */ + public function create(User $user): bool + { + return $user->hasPermission('admin.service_cancellations.create'); + } + + /** + * Determine whether the user can update the model. + */ + public function update(User $user, ServiceCancellation $category): bool + { + return $user->hasPermission('admin.service_cancellations.update'); + } + + /** + * Determine whether the user can delete the model. + */ + public function delete(User $user, ServiceCancellation $category): bool + { + return $user->hasPermission('admin.service_cancellations.delete'); + } + + /** + * Determine whether the user can bulk delete the model. + */ + public function deleteAny(User $user): bool + { + return $user->hasPermission('admin.service_cancellations.deleteAny'); + } +} diff --git a/app/Policies/ServicePolicy.php b/app/Policies/ServicePolicy.php new file mode 100644 index 000000000..88c119678 --- /dev/null +++ b/app/Policies/ServicePolicy.php @@ -0,0 +1,57 @@ +hasPermission('admin.services.viewAny'); + } + + /** + * Determine whether the user can view the model. + */ + public function view(User $user, Service $service): bool + { + return $this->adminPermission($user, 'admin.services.view') || $service->user_id === $user->id; + } + + /** + * Determine whether the user can create models. + */ + public function create(User $user): bool + { + return $user->hasPermission('admin.services.create'); + } + + /** + * Determine whether the user can update the model. + */ + public function update(User $user, Service $service): bool + { + return $this->adminPermission($user, 'admin.services.update') || $service->user_id === $user->id; + } + + /** + * Determine whether the user can delete the model. + */ + public function delete(User $user, Service $service): bool + { + return $user->hasPermission('admin.services.delete'); + } + + /** + * Determine whether the user can permanently delete any models. + */ + public function deleteAny(User $user): bool + { + return $user->hasPermission('admin.services.deleteAny'); + } +} diff --git a/app/Policies/TaxRatePolicy.php b/app/Policies/TaxRatePolicy.php new file mode 100644 index 000000000..96d032a67 --- /dev/null +++ b/app/Policies/TaxRatePolicy.php @@ -0,0 +1,57 @@ +hasPermission('admin.tax_rates.viewAny'); + } + + /** + * Determine whether the user can view the model. + */ + public function view(User $user, TaxRate $taxRate): bool + { + return $user->hasPermission('admin.tax_rates.view'); + } + + /** + * Determine whether the user can create models. + */ + public function create(User $user): bool + { + return $user->hasPermission('admin.tax_rates.create'); + } + + /** + * Determine whether the user can update the model. + */ + public function update(User $user, TaxRate $taxRate): bool + { + return $user->hasPermission('admin.tax_rates.update'); + } + + /** + * Determine whether the user can delete the model. + */ + public function delete(User $user, TaxRate $taxRate): bool + { + return $user->hasPermission('admin.tax_rates.delete'); + } + + /** + * Determine whether the user can delete multiple models. + */ + public function deleteAny(User $user): bool + { + return $user->hasPermission('admin.tax_rates.deleteAny'); + } +} diff --git a/app/Policies/TicketMessagePolicy.php b/app/Policies/TicketMessagePolicy.php new file mode 100644 index 000000000..d2c5e9136 --- /dev/null +++ b/app/Policies/TicketMessagePolicy.php @@ -0,0 +1,17 @@ +hasPermission('admin.ticket_messages.delete'); + } +} diff --git a/app/Policies/TicketPolicy.php b/app/Policies/TicketPolicy.php new file mode 100644 index 000000000..5dd590bd3 --- /dev/null +++ b/app/Policies/TicketPolicy.php @@ -0,0 +1,57 @@ +hasPermission('admin.tickets.viewAny'); + } + + /** + * Determine whether the user can view the model. + */ + public function view(User $user, Ticket $ticket): bool + { + return $user->hasPermission('admin.tickets.view') || $ticket->user_id === $user->id; + } + + /** + * Determine whether the user can create models. + */ + public function create(User $user): bool + { + return $user->hasPermission('admin.tickets.create'); + } + + /** + * Determine whether the user can update the model. + */ + public function update(User $user, Ticket $ticket): bool + { + return $user->hasPermission('admin.tickets.update') || $ticket->user_id === $user->id; + } + + /** + * Determine whether the user can delete the model. + */ + public function delete(User $user, Ticket $ticket): bool + { + return $user->hasPermission('admin.tickets.delete'); + } + + /** + * Determine whether the user can delete multiple models. + */ + public function deleteAny(User $user): bool + { + return $user->hasPermission('admin.tickets.deleteAny'); + } +} diff --git a/app/Policies/UserPolicy.php b/app/Policies/UserPolicy.php new file mode 100644 index 000000000..d22d3f86b --- /dev/null +++ b/app/Policies/UserPolicy.php @@ -0,0 +1,56 @@ +hasPermission('admin.users.viewAny'); + } + + /** + * Determine whether the user can view the model. + */ + public function view(User $user, User $model): bool + { + return $user->hasPermission('admin.users.view'); + } + + /** + * Determine whether the user can create models. + */ + public function create(User $user): bool + { + return $user->hasPermission('admin.users.create'); + } + + /** + * Determine whether the user can update the model. + */ + public function update(User $user, User $model): bool + { + return $user->hasPermission('admin.users.update'); + } + + /** + * Determine whether the user can delete the model. + */ + public function delete(User $user, User $model): bool + { + return $user->hasPermission('admin.users.delete'); + } + + /** + * Determine whether the user can delete the model. + */ + public function deleteAny(User $user): bool + { + return $user->hasPermission('admin.users.deleteAny'); + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php new file mode 100644 index 000000000..18a224ee1 --- /dev/null +++ b/app/Providers/AppServiceProvider.php @@ -0,0 +1,163 @@ +app->register(SettingsProvider::class); + + UrlGenerator::macro('alternateHasCorrectSignature', function (Request $request, $absolute = true, Closure|array $ignoreQuery = []) { + // ensure the base path is applied to absolute url + $absoluteUrl = url($request->path()); // forceRootUrl and forceScheme will apply + $url = $absolute ? $absoluteUrl : '/' . $request->path(); + + $queryString = collect(explode('&', (string) $request->server->get('QUERY_STRING'))) + ->reject(function ($parameter) use ($ignoreQuery) { + $parameter = Str::before($parameter, '='); + + if ($parameter === 'signature') { + return true; + } + + if ($ignoreQuery instanceof Closure) { + return $ignoreQuery($parameter); + } + + return in_array($parameter, $ignoreQuery); + }) + ->join('&'); + + $original = rtrim($url . '?' . $queryString, '?'); + + $keys = call_user_func($this->keyResolver); + + $keys = is_array($keys) ? $keys : [$keys]; + + foreach ($keys as $key) { + if ( + hash_equals( + hash_hmac('sha256', $original, $key), + (string) $request->query('signature', '') + ) + ) { + return true; + } + } + + return false; + }); + + UrlGenerator::macro('alternateHasValidSignature', function (Request $request, $absolute = true, array $ignoreQuery = []) { + return \URL::alternateHasCorrectSignature($request, $absolute, $ignoreQuery) + && \URL::signatureHasNotExpired($request); + }); + + Request::macro('hasValidSignature', function ($absolute = true, array $ignoreQuery = []) { + return \URL::alternateHasValidSignature($this, $absolute, $ignoreQuery); + }); + } + + /** + * Bootstrap any application services. + */ + public function boot(): void + { + // Change livewire url + \Livewire\Livewire::setUpdateRoute(function ($handle) { + return \Illuminate\Support\Facades\Route::post('/paymenter/update', $handle)->middleware('web')->name('paymenter.'); + }); + \Livewire\Livewire::propertySynthesizer(PriceSynth::class); + + Gate::define('has-permission', function (User $user, string $ability) { + return $user->hasPermission($ability); + }); + + Event::listen(function (\SocialiteProviders\Manager\SocialiteWasCalled $event) { + $event->extendSocialite('discord', \SocialiteProviders\Discord\Provider::class); + }); + + try { + foreach ( + collect(Extension::where(function ($query) { + $query->where('enabled', true)->orWhere('type', 'server')->orWhere('type', 'gateway'); + })->get())->unique('extension') as $extension + ) { + ExtensionHelper::call($extension, 'boot', mayFail: true); + } + } catch (\Exception $e) { + // Fail silently + } + + Queue::after(function (JobProcessed $event) { + if ($event->job->resolveName() === 'App\Mail\Mail') { + $payload = json_decode($event->job->getRawBody()); + $data = unserialize($payload->data->command); + EmailLog::where('id', $data->mailable->email_log_id)->update([ + 'sent_at' => now(), + 'status' => 'sent', + ]); + } + }); + Queue::failing(function (JobFailed $event) { + if ($event->job->resolveName() === 'App\Mail\Mail') { + $payload = json_decode($event->job->getRawBody()); + $data = unserialize($payload->data->command); + EmailLog::where('id', $data->mailable->email_log_id)->update([ + 'status' => 'failed', + 'error' => $event->exception->getMessage(), + 'job_uuid' => $event->job->uuid(), + ]); + } + }); + + Str::macro('markdown', function ($markdown) { + return Str::markdown($markdown, extensions: [ + new TableExtension, + ]); + }); + Passport::clientModel(OauthClient::class); + Passport::ignoreRoutes(); + Passport::tokensCan(ScopeRegistry::getAll()); + + if (class_exists(Scramble::class)) { + Scramble::configure() + ->routes(function (\Illuminate\Routing\Route $route) { + return Str::startsWith($route->uri, 'api/v1/admin'); + }) + ->withDocumentTransformers(function (OpenApi $openApi) { + $openApi->secure( + SecurityScheme::http('bearer') + ); + }); + } + } +} diff --git a/app/Providers/Filament/AdminPanelProvider.php b/app/Providers/Filament/AdminPanelProvider.php new file mode 100644 index 000000000..9f29b5b57 --- /dev/null +++ b/app/Providers/Filament/AdminPanelProvider.php @@ -0,0 +1,100 @@ +default() + ->id('admin') + ->path('admin') + ->spa() + ->colors([ + 'primary' => Color::Blue, + ]) + ->globalSearchKeyBindings(['command+k', 'ctrl+k']) + ->favicon(config('settings.logo') ? Storage::url(config('settings.logo')) : null) + ->discoverResources(in: app_path('Admin/Resources'), for: 'App\\Admin\\Resources') + ->discoverPages(in: app_path('Admin/Pages'), for: 'App\\Admin\\Pages') + ->discoverClusters(in: app_path('Admin/Clusters'), for: 'App\\Admin\\Clusters') + ->userMenuItems([ + MenuItem::make() + ->label('Exit Admin') + ->url('/') + ->icon('heroicon-s-arrow-uturn-left') + ->sort(24), + ]) + ->discoverWidgets(in: app_path('Admin/Widgets'), for: 'App\\Admin\\Widgets') + ->renderHook( + PanelsRenderHook::SIDEBAR_NAV_END, + fn (): string => Blade::render(''), + ) + ->navigationGroups([ + 'Administration', + 'Configuration', + 'Extensions', + 'System', + ]) + ->middleware([ + EncryptCookies::class, + AddQueuedCookiesToResponse::class, + StartSession::class, + ImpersonateMiddleware::class, + AuthenticateSession::class, + ShareErrorsFromSession::class, + VerifyCsrfToken::class, + SubstituteBindings::class, + DisableBladeIconComponents::class, + DispatchServingFilamentEvent::class, + ]) + ->theme(asset('css/filament/admin/theme.css')) + ->authMiddleware([ + Authenticate::class, + ]); + + try { + foreach (collect(Extension::where(function ($query) { + $query->where('enabled', true)->orWhere('type', 'server')->orWhere('type', 'gateway'); + })->get())->unique('extension') as $extension) { + $panel->discoverResources(in: base_path('extensions' . '/' . $extension->path . '/Admin/Resources'), for: $extension->namespace . '\\Admin\\Resources'); + $panel->discoverPages(in: base_path('extensions' . '/' . $extension->path . '/Admin/Pages'), for: $extension->namespace . '\\Admin\\Pages'); + $panel->discoverClusters(in: base_path('extensions' . '/' . $extension->path . '/Admin/Clusters'), for: $extension->namespace . '\\Admin\\Clusters'); + } + } catch (\Exception $e) { + // Do nothing + } + + return $panel; + } +} diff --git a/app/Providers/SettingsProvider.php b/app/Providers/SettingsProvider.php new file mode 100644 index 000000000..8e437b119 --- /dev/null +++ b/app/Providers/SettingsProvider.php @@ -0,0 +1,78 @@ +getSettings(); + } + + public static function getSettings($force = false): void + { + if (config('settings') && !empty(config('settings')) && !$force) { + return; + } + try { + // Load settings from cache + $settings = Cache::get('settings', []); + if (empty($settings)) { + $settings = Setting::where('settingable_type', null)->get()->pluck('value', 'key'); + Cache::put('settings', $settings); + } + // Is the current command a config:cache command? + if (isset($_SERVER['argv']) && (in_array('config:cache', $_SERVER['argv']) || in_array('optimize', $_SERVER['argv']) || in_array('app:optimize', $_SERVER['argv']))) { + return; + } + config(['settings' => $settings]); + foreach (\App\Classes\Settings::settings() as $settings) { + foreach ($settings as $setting) { + if (isset($setting['override']) && config("settings.$setting[name]") !== null) { + config([$setting['override'] => config("settings.$setting[name]")]); + } + } + } + + include_once app_path('Classes/helpers.php'); + + date_default_timezone_set(config('settings.timezone', 'UTC')); + + Theme::set(config('settings.theme', 'default'), 'default'); + + if (Str::startsWith(config('app.url') ?? '', 'https://')) { + URL::forceScheme('https'); + } + URL::forceRootUrl(config('app.url')); + + Config::set('filesystems.disks.public.url', config('app.url') . '/storage'); + } catch (\Exception $e) { + // Do nothing + } + } + + public static function flushCache() + { + Cache::forget('settings'); + // Restart queue worker + Artisan::call('queue:restart'); + self::getSettings(true); + } +} diff --git a/app/Rules/Domain.php b/app/Rules/Domain.php new file mode 100644 index 000000000..657d19a67 --- /dev/null +++ b/app/Rules/Domain.php @@ -0,0 +1,16 @@ + 'View your profile', + ]; + + public static function add(string $scope, string $description): void + { + if (!array_key_exists($scope, static::$scopes)) { + static::$scopes[$scope] = $description; + } + } + + public static function addMany(array $scopes): void + { + foreach ($scopes as $key => $desc) { + static::add($key, $desc); + } + } + + public static function getAll(): array + { + return static::$scopes; + } +} diff --git a/app/Traits/Captchable.php b/app/Traits/Captchable.php new file mode 100644 index 000000000..de3f2cb90 --- /dev/null +++ b/app/Traits/Captchable.php @@ -0,0 +1,106 @@ +captcha(); + } + + // Captchable + private function captcha() + { + if (!config('settings.captcha') || config('settings.captcha') == 'disabled' || $this->is_valid) { + return; + } + + if (!$this->captcha) { + throw ValidationException::withMessages(['captcha' => 'The CAPTCHA is required.']); + } + + if (config('settings.captcha') == 'turnstile') { + $this->turnstile($this->captcha); + } elseif (config('settings.captcha') == 'hcaptcha') { + $this->hcaptcha($this->captcha); + } elseif (config('settings.captcha') == 'recaptcha-v2' || config('settings.captcha') == 'recaptcha-v3') { + $this->recaptcha($this->captcha); + } + } + + // Turnstile + private function turnstile($value) + { + $itempotencyKey = uniqid(); + + $response = Http::asForm()->acceptJson()->post('https://challenges.cloudflare.com/turnstile/v0/siteverify', [ + 'secret' => config('settings.captcha_secret'), + 'response' => $value, + 'remoteip' => request()->ip(), + 'idempotency_key' => $itempotencyKey, + ]); + + if ($response->json()['success']) { + return $this->is_valid = true; + } + + $subResponse = Http::asForm()->acceptJson()->post('https://challenges.cloudflare.com/turnstile/v0/siteverify', [ + 'secret' => config('settings.captcha_secret'), + 'response' => $value, + 'remoteip' => request()->ip(), + 'idempotency_key' => $itempotencyKey, + ]); + + if ($subResponse->json()['success']) { + return $this->is_valid = true; + } + + throw ValidationException::withMessages(['captcha' => 'The CAPTCHA was invalid.']); + + return $this->is_valid = false; + } + + // Google Recaptcha + private function recaptcha($value) + { + $response = Http::asForm()->acceptJson()->post('https://www.google.com/recaptcha/api/siteverify', [ + 'secret' => config('settings.captcha_secret'), + 'response' => $value, + 'remoteip' => request()->ip(), + ]); + + if ($response->json()['success']) { + return $this->is_valid = true; + } + + throw ValidationException::withMessages(['captcha' => 'The CAPTCHA was invalid.']); + + return $this->is_valid = false; + } + + // hCaptcha + private function hcaptcha($value) + { + $response = Http::asForm()->acceptJson()->post('https://api.hcaptcha.com/siteverify', [ + 'secret' => config('settings.captcha_secret'), + 'response' => $value, + 'remoteip' => request()->ip(), + ]); + + if ($response->json()['success']) { + return $this->is_valid = true; + } + + throw ValidationException::withMessages(['captcha' => 'The CAPTCHA was invalid.']); + + return $this->is_valid = false; + } +} diff --git a/app/View/Components/AppLayout.php b/app/View/Components/AppLayout.php new file mode 100644 index 000000000..ec3e64fa4 --- /dev/null +++ b/app/View/Components/AppLayout.php @@ -0,0 +1,34 @@ +title = $title; + $this->clients = $clients ? true : false; + $this->description = $description; + $this->image = $image; + } + + /** + * Get the view / contents that represents the component. + * + * @return \Illuminate\View\View + */ + public function render() + { + return view('layouts.app'); + } +} diff --git a/app/View/Components/Bloading.php b/app/View/Components/Bloading.php new file mode 100644 index 000000000..293718abc --- /dev/null +++ b/app/View/Components/Bloading.php @@ -0,0 +1,26 @@ +handleCommand(new ArgvInput); + +exit($status); diff --git a/bootstrap/app.php b/bootstrap/app.php new file mode 100644 index 000000000..e7b1eb8bb --- /dev/null +++ b/bootstrap/app.php @@ -0,0 +1,58 @@ +withRouting( + web: __DIR__ . '/../routes/web.php', + api: __DIR__ . '/../routes/api.php', + commands: __DIR__ . '/../routes/console.php', + // channels: __DIR__.'/../routes/channels.php', + ) + ->withMiddleware(function (Middleware $middleware) { + $middleware->append(ProxyMiddleware::class); + $middleware->alias([ + 'has' => EnsureUserHasPermissions::class, + 'scope' => CheckForAnyScope::class, + 'api.admin' => AdminApi::class, + ]); + $middleware->web([ + SetLocale::class, + ImpersonateMiddleware::class, + ]); + }) + ->withEvents(discover: [ + __DIR__ . '/../app/Extensions', + __DIR__ . '/../app/Listeners', + ]) + ->withExceptions(function (Exceptions $exceptions) { + $exceptions->report(function (Exception $exception) { + try { + + if (!config('settings.debug', false)) { + return; + } + DebugLog::create([ + 'type' => 'exception', + 'context' => [ + 'message' => $exception->getMessage(), + 'file' => $exception->getFile(), + 'line' => $exception->getLine(), + 'trace' => $exception->getTraceAsString(), + ], + ]); + } catch (Exception $e) { + // Do nothing + throw $e; + } + }); + })->create(); diff --git a/bootstrap/cache/.gitignore b/bootstrap/cache/.gitignore new file mode 100755 index 000000000..d6b7ef32c --- /dev/null +++ b/bootstrap/cache/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/bootstrap/providers.php b/bootstrap/providers.php new file mode 100644 index 000000000..395dfa696 --- /dev/null +++ b/bootstrap/providers.php @@ -0,0 +1,9 @@ +/dev/null 2>&1; then + php -r "if (function_exists('opcache_reset')) opcache_reset();" +fi diff --git a/composer.json b/composer.json new file mode 100644 index 000000000..fb8ff9afe --- /dev/null +++ b/composer.json @@ -0,0 +1,102 @@ +{ + "name": "paymenter/paymenter", + "type": "project", + "description": "The skeleton application for the Laravel framework.", + "keywords": [ + "laravel", + "framework" + ], + "license": "MIT", + "version": "1.2.10+VBv3", + "require": { + "php": "^8.2 || ^8.3", + "ext-intl": "*", + "ext-json": "*", + "ext-mbstring": "*", + "ext-pdo": "*", + "ext-pdo_mysql": "*", + "ext-zip": "*", + "akaunting/laravel-apexcharts": "^3.2", + "andreiio/blade-remix-icon": "^3.6.0", + "barryvdh/laravel-dompdf": "^3.0", + "endroid/qr-code": "^5.0", + "filament/filament": "^3.2", + "flowframe/laravel-trend": "^0.4.0", + "laravel/framework": "^12.0", + "laravel/octane": "^2.13", + "laravel/passport": "^12.0", + "laravel/socialite": "^5.14", + "laravel/tinker": "^2.9", + "qirolab/laravel-themer": "^2.2.1", + "robthree/twofactorauth": "^3.0", + "socialiteproviders/discord": "^4.2", + "spatie/laravel-cookie-consent": "^3.3", + "spatie/laravel-query-builder": "^6.3", + "timacdonald/json-api": "^1.0.0-beta.4", + "whitecube/laravel-cookie-consent": "^1.3" + }, + "require-dev": { + "barryvdh/laravel-debugbar": "^3.13", + "dedoc/scramble": "^0.12.21", + "fakerphp/faker": "^1.23", + "larastan/larastan": "^3.0", + "laravel/pint": "^1.13", + "laravel/sail": "^1.26", + "livewire/livewire": "^3.4", + "mockery/mockery": "^1.6", + "nunomaduro/collision": "^8.0", + "phpunit/phpunit": "^11.0", + "spatie/laravel-ignition": "^2.4" + }, + "autoload": { + "psr-4": { + "App\\": "app/", + "Database\\Factories\\": "database/factories/", + "Database\\Seeders\\": "database/seeders/", + "Paymenter\\Extensions\\": "extensions/" + } + }, + "autoload-dev": { + "psr-4": { + "Tests\\": "tests/" + } + }, + "scripts": { + "post-autoload-dump": [ + "Illuminate\\Foundation\\ComposerScripts::postAutoloadDump", + "@php artisan package:discover --ansi", + "@php artisan filament:upgrade" + ], + "post-update-cmd": [ + "@php artisan vendor:publish --tag=laravel-assets --ansi --force" + ], + "post-root-package-install": [ + "@php -r \"file_exists('.env') || copy('.env.example', '.env');\"" + ], + "post-create-project-cmd": [ + "@php artisan key:generate --ansi", + "@php -r \"file_exists('database/database.sqlite') || touch('database/database.sqlite');\"", + "@php artisan migrate --graceful --ansi" + ], + "dev": [ + "Composer\\Config::disableProcessTimeout", + "npx concurrently -c \"#c4b5fd,#fdba74\" \"php artisan queue:listen --tries=1\" \"npm run dev\" --names=queue,vite" + ] + }, + "extra": { + "laravel": { + "dont-discover": [] + } + }, + "config": { + "optimize-autoloader": true, + "preferred-install": "dist", + "sort-packages": true, + "allow-plugins": { + "pestphp/pest-plugin": true, + "php-http/discovery": true + } + }, + "minimum-stability": "stable", + "prefer-stable": true +} diff --git a/composer.lock b/composer.lock new file mode 100644 index 000000000..c8adf46db --- /dev/null +++ b/composer.lock @@ -0,0 +1,13088 @@ +{ + "_readme": [ + "This file locks the dependencies of your project to a known state", + "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", + "This file is @generated automatically" + ], + "content-hash": "d5560ff298e1573005897ade28e3c6d5", + "packages": [ + { + "name": "akaunting/laravel-apexcharts", + "version": "3.2.0", + "source": { + "type": "git", + "url": "https://github.com/akaunting/laravel-apexcharts.git", + "reference": "e44fb0b858ad3e87557d2aea822dac8a86d048f3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/akaunting/laravel-apexcharts/zipball/e44fb0b858ad3e87557d2aea822dac8a86d048f3", + "reference": "e44fb0b858ad3e87557d2aea822dac8a86d048f3", + "shasum": "" + }, + "require": { + "balping/json-raw-encoder": "^1.0", + "ext-json": "*", + "illuminate/support": "^9.0|^10.0|^11.0|^12.0", + "php": "^8.0" + }, + "require-dev": { + "mockery/mockery": "^1.5", + "orchestra/testbench": "^7.4|^8.0|^9.0|^10.0", + "phpunit/phpunit": "^9.5|^10.0|^11.0|^12.0" + }, + "type": "library", + "extra": { + "laravel": { + "aliases": { + "Apexcharts": "Akaunting\\Apexcharts\\Facade" + }, + "providers": [ + "Akaunting\\Apexcharts\\Provider" + ] + } + }, + "autoload": { + "psr-4": { + "Akaunting\\Apexcharts\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Cüneyt Şentürk", + "email": "info@akaunting.com", + "homepage": "https://akaunting.com", + "role": "Developer" + } + ], + "description": "ApexCharts package for Laravel", + "keywords": [ + "apexcharts", + "bar", + "charts", + "donut", + "laravel", + "line", + "pie" + ], + "support": { + "issues": "https://github.com/akaunting/laravel-apexcharts/issues", + "source": "https://github.com/akaunting/laravel-apexcharts/tree/3.2.0" + }, + "time": "2025-04-04T21:02:33+00:00" + }, + { + "name": "andreiio/blade-remix-icon", + "version": "3.6.0", + "source": { + "type": "git", + "url": "https://github.com/andreiio/blade-remix-icon.git", + "reference": "c70c6f3e9b578aed9ab5901171aa2286ac1a58e8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/andreiio/blade-remix-icon/zipball/c70c6f3e9b578aed9ab5901171aa2286ac1a58e8", + "reference": "c70c6f3e9b578aed9ab5901171aa2286ac1a58e8", + "shasum": "" + }, + "require": { + "blade-ui-kit/blade-icons": "^1.0", + "php": "^7.4|^8.0|^8.1" + }, + "require-dev": { + "orchestra/testbench": "^6.0|^7.0|^8.0", + "phpunit/phpunit": "^9.0" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "AndreiIonita\\BladeRemixIcon\\BladeRemixIconServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "AndreiIonita\\BladeRemixIcon\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Andrei Ioniță", + "homepage": "https://andrei.io" + } + ], + "description": "A package to easily make use of Remix Icon in your Laravel Blade views.", + "support": { + "issues": "https://github.com/andreiio/blade-remix-icon/issues", + "source": "https://github.com/andreiio/blade-remix-icon/tree/3.6.0" + }, + "funding": [ + { + "url": "https://github.com/code4romania", + "type": "github" + } + ], + "time": "2024-12-17T18:28:03+00:00" + }, + { + "name": "anourvalar/eloquent-serialize", + "version": "1.3.3", + "source": { + "type": "git", + "url": "https://github.com/AnourValar/eloquent-serialize.git", + "reference": "2f05023f1e465a91dc4f08483e6710325641a444" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/AnourValar/eloquent-serialize/zipball/2f05023f1e465a91dc4f08483e6710325641a444", + "reference": "2f05023f1e465a91dc4f08483e6710325641a444", + "shasum": "" + }, + "require": { + "laravel/framework": "^8.0|^9.0|^10.0|^11.0|^12.0", + "php": "^7.4|^8.0" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^3.26", + "laravel/legacy-factories": "^1.1", + "orchestra/testbench": "^6.0|^7.0|^8.0|^9.0|^10.0", + "phpstan/phpstan": "^2.0", + "phpunit/phpunit": "^9.5|^10.5|^11.0", + "psalm/plugin-laravel": "^2.8|^3.0", + "squizlabs/php_codesniffer": "^3.7" + }, + "type": "library", + "extra": { + "laravel": { + "aliases": { + "EloquentSerialize": "AnourValar\\EloquentSerialize\\Facades\\EloquentSerializeFacade" + } + } + }, + "autoload": { + "psr-4": { + "AnourValar\\EloquentSerialize\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Laravel Query Builder (Eloquent) serialization", + "homepage": "https://github.com/AnourValar/eloquent-serialize", + "keywords": [ + "anourvalar", + "builder", + "copy", + "eloquent", + "job", + "laravel", + "query", + "querybuilder", + "queue", + "serializable", + "serialization", + "serialize" + ], + "support": { + "issues": "https://github.com/AnourValar/eloquent-serialize/issues", + "source": "https://github.com/AnourValar/eloquent-serialize/tree/1.3.3" + }, + "time": "2025-05-28T17:07:28+00:00" + }, + { + "name": "bacon/bacon-qr-code", + "version": "v3.0.1", + "source": { + "type": "git", + "url": "https://github.com/Bacon/BaconQrCode.git", + "reference": "f9cc1f52b5a463062251d666761178dbdb6b544f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Bacon/BaconQrCode/zipball/f9cc1f52b5a463062251d666761178dbdb6b544f", + "reference": "f9cc1f52b5a463062251d666761178dbdb6b544f", + "shasum": "" + }, + "require": { + "dasprid/enum": "^1.0.3", + "ext-iconv": "*", + "php": "^8.1" + }, + "require-dev": { + "phly/keep-a-changelog": "^2.12", + "phpunit/phpunit": "^10.5.11 || 11.0.4", + "spatie/phpunit-snapshot-assertions": "^5.1.5", + "squizlabs/php_codesniffer": "^3.9" + }, + "suggest": { + "ext-imagick": "to generate QR code images" + }, + "type": "library", + "autoload": { + "psr-4": { + "BaconQrCode\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-2-Clause" + ], + "authors": [ + { + "name": "Ben Scholzen 'DASPRiD'", + "email": "mail@dasprids.de", + "homepage": "https://dasprids.de/", + "role": "Developer" + } + ], + "description": "BaconQrCode is a QR code generator for PHP.", + "homepage": "https://github.com/Bacon/BaconQrCode", + "support": { + "issues": "https://github.com/Bacon/BaconQrCode/issues", + "source": "https://github.com/Bacon/BaconQrCode/tree/v3.0.1" + }, + "time": "2024-10-01T13:55:55+00:00" + }, + { + "name": "balping/json-raw-encoder", + "version": "v1.0.2", + "source": { + "type": "git", + "url": "git@gitlab.com:balping/json-raw-encoder.git", + "reference": "9338e193f84dbea33edcb1cb82baea328afadefb" + }, + "dist": { + "type": "zip", + "url": "https://gitlab.com/api/v4/projects/balping%2Fjson-raw-encoder/repository/archive.zip?sha=9338e193f84dbea33edcb1cb82baea328afadefb", + "reference": "9338e193f84dbea33edcb1cb82baea328afadefb", + "shasum": "" + }, + "require": { + "php": ">=7.0" + }, + "require-dev": { + "phpunit/phpunit": "^7.1|^8.0|^9.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Balping\\JsonRaw\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "GPL-3.0-only" + ], + "authors": [ + { + "name": "Balázs Dura-Kovács" + } + ], + "description": "Encode arrays to json with raw JS objects (eg. callbacks) in them", + "keywords": [ + "callback", + "encode", + "json" + ], + "time": "2023-03-27T18:25:07+00:00" + }, + { + "name": "barryvdh/laravel-dompdf", + "version": "v3.1.1", + "source": { + "type": "git", + "url": "https://github.com/barryvdh/laravel-dompdf.git", + "reference": "8e71b99fc53bb8eb77f316c3c452dd74ab7cb25d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/barryvdh/laravel-dompdf/zipball/8e71b99fc53bb8eb77f316c3c452dd74ab7cb25d", + "reference": "8e71b99fc53bb8eb77f316c3c452dd74ab7cb25d", + "shasum": "" + }, + "require": { + "dompdf/dompdf": "^3.0", + "illuminate/support": "^9|^10|^11|^12", + "php": "^8.1" + }, + "require-dev": { + "larastan/larastan": "^2.7|^3.0", + "orchestra/testbench": "^7|^8|^9|^10", + "phpro/grumphp": "^2.5", + "squizlabs/php_codesniffer": "^3.5" + }, + "type": "library", + "extra": { + "laravel": { + "aliases": { + "PDF": "Barryvdh\\DomPDF\\Facade\\Pdf", + "Pdf": "Barryvdh\\DomPDF\\Facade\\Pdf" + }, + "providers": [ + "Barryvdh\\DomPDF\\ServiceProvider" + ] + }, + "branch-alias": { + "dev-master": "3.0-dev" + } + }, + "autoload": { + "psr-4": { + "Barryvdh\\DomPDF\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Barry vd. Heuvel", + "email": "barryvdh@gmail.com" + } + ], + "description": "A DOMPDF Wrapper for Laravel", + "keywords": [ + "dompdf", + "laravel", + "pdf" + ], + "support": { + "issues": "https://github.com/barryvdh/laravel-dompdf/issues", + "source": "https://github.com/barryvdh/laravel-dompdf/tree/v3.1.1" + }, + "funding": [ + { + "url": "https://fruitcake.nl", + "type": "custom" + }, + { + "url": "https://github.com/barryvdh", + "type": "github" + } + ], + "time": "2025-02-13T15:07:54+00:00" + }, + { + "name": "blade-ui-kit/blade-heroicons", + "version": "2.6.0", + "source": { + "type": "git", + "url": "https://github.com/driesvints/blade-heroicons.git", + "reference": "4553b2a1f6c76f0ac7f3bc0de4c0cfa06a097d19" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/driesvints/blade-heroicons/zipball/4553b2a1f6c76f0ac7f3bc0de4c0cfa06a097d19", + "reference": "4553b2a1f6c76f0ac7f3bc0de4c0cfa06a097d19", + "shasum": "" + }, + "require": { + "blade-ui-kit/blade-icons": "^1.6", + "illuminate/support": "^9.0|^10.0|^11.0|^12.0", + "php": "^8.0" + }, + "require-dev": { + "orchestra/testbench": "^7.0|^8.0|^9.0|^10.0", + "phpunit/phpunit": "^9.0|^10.5|^11.0" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "BladeUI\\Heroicons\\BladeHeroiconsServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "BladeUI\\Heroicons\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Dries Vints", + "homepage": "https://driesvints.com" + } + ], + "description": "A package to easily make use of Heroicons in your Laravel Blade views.", + "homepage": "https://github.com/blade-ui-kit/blade-heroicons", + "keywords": [ + "Heroicons", + "blade", + "laravel" + ], + "support": { + "issues": "https://github.com/driesvints/blade-heroicons/issues", + "source": "https://github.com/driesvints/blade-heroicons/tree/2.6.0" + }, + "funding": [ + { + "url": "https://github.com/sponsors/driesvints", + "type": "github" + }, + { + "url": "https://www.paypal.com/paypalme/driesvints", + "type": "paypal" + } + ], + "time": "2025-02-13T20:53:33+00:00" + }, + { + "name": "blade-ui-kit/blade-icons", + "version": "1.8.0", + "source": { + "type": "git", + "url": "https://github.com/driesvints/blade-icons.git", + "reference": "7b743f27476acb2ed04cb518213d78abe096e814" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/driesvints/blade-icons/zipball/7b743f27476acb2ed04cb518213d78abe096e814", + "reference": "7b743f27476acb2ed04cb518213d78abe096e814", + "shasum": "" + }, + "require": { + "illuminate/contracts": "^8.0|^9.0|^10.0|^11.0|^12.0", + "illuminate/filesystem": "^8.0|^9.0|^10.0|^11.0|^12.0", + "illuminate/support": "^8.0|^9.0|^10.0|^11.0|^12.0", + "illuminate/view": "^8.0|^9.0|^10.0|^11.0|^12.0", + "php": "^7.4|^8.0", + "symfony/console": "^5.3|^6.0|^7.0", + "symfony/finder": "^5.3|^6.0|^7.0" + }, + "require-dev": { + "mockery/mockery": "^1.5.1", + "orchestra/testbench": "^6.0|^7.0|^8.0|^9.0|^10.0", + "phpunit/phpunit": "^9.0|^10.5|^11.0" + }, + "bin": [ + "bin/blade-icons-generate" + ], + "type": "library", + "extra": { + "laravel": { + "providers": [ + "BladeUI\\Icons\\BladeIconsServiceProvider" + ] + } + }, + "autoload": { + "files": [ + "src/helpers.php" + ], + "psr-4": { + "BladeUI\\Icons\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Dries Vints", + "homepage": "https://driesvints.com" + } + ], + "description": "A package to easily make use of icons in your Laravel Blade views.", + "homepage": "https://github.com/blade-ui-kit/blade-icons", + "keywords": [ + "blade", + "icons", + "laravel", + "svg" + ], + "support": { + "issues": "https://github.com/blade-ui-kit/blade-icons/issues", + "source": "https://github.com/blade-ui-kit/blade-icons" + }, + "funding": [ + { + "url": "https://github.com/sponsors/driesvints", + "type": "github" + }, + { + "url": "https://www.paypal.com/paypalme/driesvints", + "type": "paypal" + } + ], + "time": "2025-02-13T20:35:06+00:00" + }, + { + "name": "brick/math", + "version": "0.13.1", + "source": { + "type": "git", + "url": "https://github.com/brick/math.git", + "reference": "fc7ed316430118cc7836bf45faff18d5dfc8de04" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/brick/math/zipball/fc7ed316430118cc7836bf45faff18d5dfc8de04", + "reference": "fc7ed316430118cc7836bf45faff18d5dfc8de04", + "shasum": "" + }, + "require": { + "php": "^8.1" + }, + "require-dev": { + "php-coveralls/php-coveralls": "^2.2", + "phpunit/phpunit": "^10.1", + "vimeo/psalm": "6.8.8" + }, + "type": "library", + "autoload": { + "psr-4": { + "Brick\\Math\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Arbitrary-precision arithmetic library", + "keywords": [ + "Arbitrary-precision", + "BigInteger", + "BigRational", + "arithmetic", + "bigdecimal", + "bignum", + "bignumber", + "brick", + "decimal", + "integer", + "math", + "mathematics", + "rational" + ], + "support": { + "issues": "https://github.com/brick/math/issues", + "source": "https://github.com/brick/math/tree/0.13.1" + }, + "funding": [ + { + "url": "https://github.com/BenMorel", + "type": "github" + } + ], + "time": "2025-03-29T13:50:30+00:00" + }, + { + "name": "carbonphp/carbon-doctrine-types", + "version": "3.2.0", + "source": { + "type": "git", + "url": "https://github.com/CarbonPHP/carbon-doctrine-types.git", + "reference": "18ba5ddfec8976260ead6e866180bd5d2f71aa1d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/CarbonPHP/carbon-doctrine-types/zipball/18ba5ddfec8976260ead6e866180bd5d2f71aa1d", + "reference": "18ba5ddfec8976260ead6e866180bd5d2f71aa1d", + "shasum": "" + }, + "require": { + "php": "^8.1" + }, + "conflict": { + "doctrine/dbal": "<4.0.0 || >=5.0.0" + }, + "require-dev": { + "doctrine/dbal": "^4.0.0", + "nesbot/carbon": "^2.71.0 || ^3.0.0", + "phpunit/phpunit": "^10.3" + }, + "type": "library", + "autoload": { + "psr-4": { + "Carbon\\Doctrine\\": "src/Carbon/Doctrine/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "KyleKatarn", + "email": "kylekatarnls@gmail.com" + } + ], + "description": "Types to use Carbon in Doctrine", + "keywords": [ + "carbon", + "date", + "datetime", + "doctrine", + "time" + ], + "support": { + "issues": "https://github.com/CarbonPHP/carbon-doctrine-types/issues", + "source": "https://github.com/CarbonPHP/carbon-doctrine-types/tree/3.2.0" + }, + "funding": [ + { + "url": "https://github.com/kylekatarnls", + "type": "github" + }, + { + "url": "https://opencollective.com/Carbon", + "type": "open_collective" + }, + { + "url": "https://tidelift.com/funding/github/packagist/nesbot/carbon", + "type": "tidelift" + } + ], + "time": "2024-02-09T16:56:22+00:00" + }, + { + "name": "danharrin/date-format-converter", + "version": "v0.3.1", + "source": { + "type": "git", + "url": "https://github.com/danharrin/date-format-converter.git", + "reference": "7c31171bc981e48726729a5f3a05a2d2b63f0b1e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/danharrin/date-format-converter/zipball/7c31171bc981e48726729a5f3a05a2d2b63f0b1e", + "reference": "7c31171bc981e48726729a5f3a05a2d2b63f0b1e", + "shasum": "" + }, + "require": { + "php": "^7.2|^8.0" + }, + "type": "library", + "autoload": { + "files": [ + "src/helpers.php", + "src/standards.php" + ], + "psr-4": { + "DanHarrin\\DateFormatConverter\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Dan Harrin", + "email": "dan@danharrin.com" + } + ], + "description": "Convert token-based date formats between standards.", + "homepage": "https://github.com/danharrin/date-format-converter", + "support": { + "issues": "https://github.com/danharrin/date-format-converter/issues", + "source": "https://github.com/danharrin/date-format-converter" + }, + "funding": [ + { + "url": "https://github.com/danharrin", + "type": "github" + } + ], + "time": "2024-06-13T09:38:44+00:00" + }, + { + "name": "danharrin/livewire-rate-limiting", + "version": "v2.1.0", + "source": { + "type": "git", + "url": "https://github.com/danharrin/livewire-rate-limiting.git", + "reference": "14dde653a9ae8f38af07a0ba4921dc046235e1a0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/danharrin/livewire-rate-limiting/zipball/14dde653a9ae8f38af07a0ba4921dc046235e1a0", + "reference": "14dde653a9ae8f38af07a0ba4921dc046235e1a0", + "shasum": "" + }, + "require": { + "illuminate/support": "^9.0|^10.0|^11.0|^12.0", + "php": "^8.0" + }, + "require-dev": { + "livewire/livewire": "^3.0", + "livewire/volt": "^1.3", + "orchestra/testbench": "^7.0|^8.0|^9.0|^10.0", + "phpunit/phpunit": "^9.0|^10.0|^11.5.3" + }, + "type": "library", + "autoload": { + "psr-4": { + "DanHarrin\\LivewireRateLimiting\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Dan Harrin", + "email": "dan@danharrin.com" + } + ], + "description": "Apply rate limiters to Laravel Livewire actions.", + "homepage": "https://github.com/danharrin/livewire-rate-limiting", + "support": { + "issues": "https://github.com/danharrin/livewire-rate-limiting/issues", + "source": "https://github.com/danharrin/livewire-rate-limiting" + }, + "funding": [ + { + "url": "https://github.com/danharrin", + "type": "github" + } + ], + "time": "2025-02-21T08:52:11+00:00" + }, + { + "name": "dasprid/enum", + "version": "1.0.6", + "source": { + "type": "git", + "url": "https://github.com/DASPRiD/Enum.git", + "reference": "8dfd07c6d2cf31c8da90c53b83c026c7696dda90" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/DASPRiD/Enum/zipball/8dfd07c6d2cf31c8da90c53b83c026c7696dda90", + "reference": "8dfd07c6d2cf31c8da90c53b83c026c7696dda90", + "shasum": "" + }, + "require": { + "php": ">=7.1 <9.0" + }, + "require-dev": { + "phpunit/phpunit": "^7 || ^8 || ^9 || ^10 || ^11", + "squizlabs/php_codesniffer": "*" + }, + "type": "library", + "autoload": { + "psr-4": { + "DASPRiD\\Enum\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-2-Clause" + ], + "authors": [ + { + "name": "Ben Scholzen 'DASPRiD'", + "email": "mail@dasprids.de", + "homepage": "https://dasprids.de/", + "role": "Developer" + } + ], + "description": "PHP 7.1 enum implementation", + "keywords": [ + "enum", + "map" + ], + "support": { + "issues": "https://github.com/DASPRiD/Enum/issues", + "source": "https://github.com/DASPRiD/Enum/tree/1.0.6" + }, + "time": "2024-08-09T14:30:48+00:00" + }, + { + "name": "defuse/php-encryption", + "version": "v2.4.0", + "source": { + "type": "git", + "url": "https://github.com/defuse/php-encryption.git", + "reference": "f53396c2d34225064647a05ca76c1da9d99e5828" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/defuse/php-encryption/zipball/f53396c2d34225064647a05ca76c1da9d99e5828", + "reference": "f53396c2d34225064647a05ca76c1da9d99e5828", + "shasum": "" + }, + "require": { + "ext-openssl": "*", + "paragonie/random_compat": ">= 2", + "php": ">=5.6.0" + }, + "require-dev": { + "phpunit/phpunit": "^5|^6|^7|^8|^9|^10", + "yoast/phpunit-polyfills": "^2.0.0" + }, + "bin": [ + "bin/generate-defuse-key" + ], + "type": "library", + "autoload": { + "psr-4": { + "Defuse\\Crypto\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Hornby", + "email": "taylor@defuse.ca", + "homepage": "https://defuse.ca/" + }, + { + "name": "Scott Arciszewski", + "email": "info@paragonie.com", + "homepage": "https://paragonie.com" + } + ], + "description": "Secure PHP Encryption Library", + "keywords": [ + "aes", + "authenticated encryption", + "cipher", + "crypto", + "cryptography", + "encrypt", + "encryption", + "openssl", + "security", + "symmetric key cryptography" + ], + "support": { + "issues": "https://github.com/defuse/php-encryption/issues", + "source": "https://github.com/defuse/php-encryption/tree/v2.4.0" + }, + "time": "2023-06-19T06:10:36+00:00" + }, + { + "name": "dflydev/dot-access-data", + "version": "v3.0.3", + "source": { + "type": "git", + "url": "https://github.com/dflydev/dflydev-dot-access-data.git", + "reference": "a23a2bf4f31d3518f3ecb38660c95715dfead60f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/dflydev/dflydev-dot-access-data/zipball/a23a2bf4f31d3518f3ecb38660c95715dfead60f", + "reference": "a23a2bf4f31d3518f3ecb38660c95715dfead60f", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0" + }, + "require-dev": { + "phpstan/phpstan": "^0.12.42", + "phpunit/phpunit": "^7.5 || ^8.5 || ^9.3", + "scrutinizer/ocular": "1.6.0", + "squizlabs/php_codesniffer": "^3.5", + "vimeo/psalm": "^4.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Dflydev\\DotAccessData\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Dragonfly Development Inc.", + "email": "info@dflydev.com", + "homepage": "http://dflydev.com" + }, + { + "name": "Beau Simensen", + "email": "beau@dflydev.com", + "homepage": "http://beausimensen.com" + }, + { + "name": "Carlos Frutos", + "email": "carlos@kiwing.it", + "homepage": "https://github.com/cfrutos" + }, + { + "name": "Colin O'Dell", + "email": "colinodell@gmail.com", + "homepage": "https://www.colinodell.com" + } + ], + "description": "Given a deep data structure, access data by dot notation.", + "homepage": "https://github.com/dflydev/dflydev-dot-access-data", + "keywords": [ + "access", + "data", + "dot", + "notation" + ], + "support": { + "issues": "https://github.com/dflydev/dflydev-dot-access-data/issues", + "source": "https://github.com/dflydev/dflydev-dot-access-data/tree/v3.0.3" + }, + "time": "2024-07-08T12:26:09+00:00" + }, + { + "name": "doctrine/dbal", + "version": "4.3.0", + "source": { + "type": "git", + "url": "https://github.com/doctrine/dbal.git", + "reference": "5fe09532be619202d59c70956c6fb20e97933ee3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/dbal/zipball/5fe09532be619202d59c70956c6fb20e97933ee3", + "reference": "5fe09532be619202d59c70956c6fb20e97933ee3", + "shasum": "" + }, + "require": { + "doctrine/deprecations": "^1.1.5", + "php": "^8.2", + "psr/cache": "^1|^2|^3", + "psr/log": "^1|^2|^3" + }, + "require-dev": { + "doctrine/coding-standard": "13.0.0", + "fig/log-test": "^1", + "jetbrains/phpstorm-stubs": "2023.2", + "phpstan/phpstan": "2.1.17", + "phpstan/phpstan-phpunit": "2.0.6", + "phpstan/phpstan-strict-rules": "^2", + "phpunit/phpunit": "11.5.23", + "slevomat/coding-standard": "8.16.2", + "squizlabs/php_codesniffer": "3.13.1", + "symfony/cache": "^6.3.8|^7.0", + "symfony/console": "^5.4|^6.3|^7.0" + }, + "suggest": { + "symfony/console": "For helpful console commands such as SQL execution and import of files." + }, + "type": "library", + "autoload": { + "psr-4": { + "Doctrine\\DBAL\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Guilherme Blanco", + "email": "guilhermeblanco@gmail.com" + }, + { + "name": "Roman Borschel", + "email": "roman@code-factory.org" + }, + { + "name": "Benjamin Eberlei", + "email": "kontakt@beberlei.de" + }, + { + "name": "Jonathan Wage", + "email": "jonwage@gmail.com" + } + ], + "description": "Powerful PHP database abstraction layer (DBAL) with many features for database schema introspection and management.", + "homepage": "https://www.doctrine-project.org/projects/dbal.html", + "keywords": [ + "abstraction", + "database", + "db2", + "dbal", + "mariadb", + "mssql", + "mysql", + "oci8", + "oracle", + "pdo", + "pgsql", + "postgresql", + "queryobject", + "sasql", + "sql", + "sqlite", + "sqlserver", + "sqlsrv" + ], + "support": { + "issues": "https://github.com/doctrine/dbal/issues", + "source": "https://github.com/doctrine/dbal/tree/4.3.0" + }, + "funding": [ + { + "url": "https://www.doctrine-project.org/sponsorship.html", + "type": "custom" + }, + { + "url": "https://www.patreon.com/phpdoctrine", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/doctrine%2Fdbal", + "type": "tidelift" + } + ], + "time": "2025-06-16T19:31:04+00:00" + }, + { + "name": "doctrine/deprecations", + "version": "1.1.5", + "source": { + "type": "git", + "url": "https://github.com/doctrine/deprecations.git", + "reference": "459c2f5dd3d6a4633d3b5f46ee2b1c40f57d3f38" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/deprecations/zipball/459c2f5dd3d6a4633d3b5f46ee2b1c40f57d3f38", + "reference": "459c2f5dd3d6a4633d3b5f46ee2b1c40f57d3f38", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0" + }, + "conflict": { + "phpunit/phpunit": "<=7.5 || >=13" + }, + "require-dev": { + "doctrine/coding-standard": "^9 || ^12 || ^13", + "phpstan/phpstan": "1.4.10 || 2.1.11", + "phpstan/phpstan-phpunit": "^1.0 || ^2", + "phpunit/phpunit": "^7.5 || ^8.5 || ^9.6 || ^10.5 || ^11.5 || ^12", + "psr/log": "^1 || ^2 || ^3" + }, + "suggest": { + "psr/log": "Allows logging deprecations via PSR-3 logger implementation" + }, + "type": "library", + "autoload": { + "psr-4": { + "Doctrine\\Deprecations\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "A small layer on top of trigger_error(E_USER_DEPRECATED) or PSR-3 logging with options to disable all deprecations or selectively for packages.", + "homepage": "https://www.doctrine-project.org/", + "support": { + "issues": "https://github.com/doctrine/deprecations/issues", + "source": "https://github.com/doctrine/deprecations/tree/1.1.5" + }, + "time": "2025-04-07T20:06:18+00:00" + }, + { + "name": "doctrine/inflector", + "version": "2.0.10", + "source": { + "type": "git", + "url": "https://github.com/doctrine/inflector.git", + "reference": "5817d0659c5b50c9b950feb9af7b9668e2c436bc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/inflector/zipball/5817d0659c5b50c9b950feb9af7b9668e2c436bc", + "reference": "5817d0659c5b50c9b950feb9af7b9668e2c436bc", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "require-dev": { + "doctrine/coding-standard": "^11.0", + "phpstan/phpstan": "^1.8", + "phpstan/phpstan-phpunit": "^1.1", + "phpstan/phpstan-strict-rules": "^1.3", + "phpunit/phpunit": "^8.5 || ^9.5", + "vimeo/psalm": "^4.25 || ^5.4" + }, + "type": "library", + "autoload": { + "psr-4": { + "Doctrine\\Inflector\\": "lib/Doctrine/Inflector" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Guilherme Blanco", + "email": "guilhermeblanco@gmail.com" + }, + { + "name": "Roman Borschel", + "email": "roman@code-factory.org" + }, + { + "name": "Benjamin Eberlei", + "email": "kontakt@beberlei.de" + }, + { + "name": "Jonathan Wage", + "email": "jonwage@gmail.com" + }, + { + "name": "Johannes Schmitt", + "email": "schmittjoh@gmail.com" + } + ], + "description": "PHP Doctrine Inflector is a small library that can perform string manipulations with regard to upper/lowercase and singular/plural forms of words.", + "homepage": "https://www.doctrine-project.org/projects/inflector.html", + "keywords": [ + "inflection", + "inflector", + "lowercase", + "manipulation", + "php", + "plural", + "singular", + "strings", + "uppercase", + "words" + ], + "support": { + "issues": "https://github.com/doctrine/inflector/issues", + "source": "https://github.com/doctrine/inflector/tree/2.0.10" + }, + "funding": [ + { + "url": "https://www.doctrine-project.org/sponsorship.html", + "type": "custom" + }, + { + "url": "https://www.patreon.com/phpdoctrine", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/doctrine%2Finflector", + "type": "tidelift" + } + ], + "time": "2024-02-18T20:23:39+00:00" + }, + { + "name": "doctrine/lexer", + "version": "3.0.1", + "source": { + "type": "git", + "url": "https://github.com/doctrine/lexer.git", + "reference": "31ad66abc0fc9e1a1f2d9bc6a42668d2fbbcd6dd" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/lexer/zipball/31ad66abc0fc9e1a1f2d9bc6a42668d2fbbcd6dd", + "reference": "31ad66abc0fc9e1a1f2d9bc6a42668d2fbbcd6dd", + "shasum": "" + }, + "require": { + "php": "^8.1" + }, + "require-dev": { + "doctrine/coding-standard": "^12", + "phpstan/phpstan": "^1.10", + "phpunit/phpunit": "^10.5", + "psalm/plugin-phpunit": "^0.18.3", + "vimeo/psalm": "^5.21" + }, + "type": "library", + "autoload": { + "psr-4": { + "Doctrine\\Common\\Lexer\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Guilherme Blanco", + "email": "guilhermeblanco@gmail.com" + }, + { + "name": "Roman Borschel", + "email": "roman@code-factory.org" + }, + { + "name": "Johannes Schmitt", + "email": "schmittjoh@gmail.com" + } + ], + "description": "PHP Doctrine Lexer parser library that can be used in Top-Down, Recursive Descent Parsers.", + "homepage": "https://www.doctrine-project.org/projects/lexer.html", + "keywords": [ + "annotations", + "docblock", + "lexer", + "parser", + "php" + ], + "support": { + "issues": "https://github.com/doctrine/lexer/issues", + "source": "https://github.com/doctrine/lexer/tree/3.0.1" + }, + "funding": [ + { + "url": "https://www.doctrine-project.org/sponsorship.html", + "type": "custom" + }, + { + "url": "https://www.patreon.com/phpdoctrine", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/doctrine%2Flexer", + "type": "tidelift" + } + ], + "time": "2024-02-05T11:56:58+00:00" + }, + { + "name": "dompdf/dompdf", + "version": "v3.1.0", + "source": { + "type": "git", + "url": "https://github.com/dompdf/dompdf.git", + "reference": "a51bd7a063a65499446919286fb18b518177155a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/dompdf/dompdf/zipball/a51bd7a063a65499446919286fb18b518177155a", + "reference": "a51bd7a063a65499446919286fb18b518177155a", + "shasum": "" + }, + "require": { + "dompdf/php-font-lib": "^1.0.0", + "dompdf/php-svg-lib": "^1.0.0", + "ext-dom": "*", + "ext-mbstring": "*", + "masterminds/html5": "^2.0", + "php": "^7.1 || ^8.0" + }, + "require-dev": { + "ext-gd": "*", + "ext-json": "*", + "ext-zip": "*", + "mockery/mockery": "^1.3", + "phpunit/phpunit": "^7.5 || ^8 || ^9 || ^10 || ^11", + "squizlabs/php_codesniffer": "^3.5", + "symfony/process": "^4.4 || ^5.4 || ^6.2 || ^7.0" + }, + "suggest": { + "ext-gd": "Needed to process images", + "ext-gmagick": "Improves image processing performance", + "ext-imagick": "Improves image processing performance", + "ext-zlib": "Needed for pdf stream compression" + }, + "type": "library", + "autoload": { + "psr-4": { + "Dompdf\\": "src/" + }, + "classmap": [ + "lib/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-2.1" + ], + "authors": [ + { + "name": "The Dompdf Community", + "homepage": "https://github.com/dompdf/dompdf/blob/master/AUTHORS.md" + } + ], + "description": "DOMPDF is a CSS 2.1 compliant HTML to PDF converter", + "homepage": "https://github.com/dompdf/dompdf", + "support": { + "issues": "https://github.com/dompdf/dompdf/issues", + "source": "https://github.com/dompdf/dompdf/tree/v3.1.0" + }, + "time": "2025-01-15T14:09:04+00:00" + }, + { + "name": "dompdf/php-font-lib", + "version": "1.0.1", + "source": { + "type": "git", + "url": "https://github.com/dompdf/php-font-lib.git", + "reference": "6137b7d4232b7f16c882c75e4ca3991dbcf6fe2d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/dompdf/php-font-lib/zipball/6137b7d4232b7f16c882c75e4ca3991dbcf6fe2d", + "reference": "6137b7d4232b7f16c882c75e4ca3991dbcf6fe2d", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "php": "^7.1 || ^8.0" + }, + "require-dev": { + "symfony/phpunit-bridge": "^3 || ^4 || ^5 || ^6" + }, + "type": "library", + "autoload": { + "psr-4": { + "FontLib\\": "src/FontLib" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-2.1-or-later" + ], + "authors": [ + { + "name": "The FontLib Community", + "homepage": "https://github.com/dompdf/php-font-lib/blob/master/AUTHORS.md" + } + ], + "description": "A library to read, parse, export and make subsets of different types of font files.", + "homepage": "https://github.com/dompdf/php-font-lib", + "support": { + "issues": "https://github.com/dompdf/php-font-lib/issues", + "source": "https://github.com/dompdf/php-font-lib/tree/1.0.1" + }, + "time": "2024-12-02T14:37:59+00:00" + }, + { + "name": "dompdf/php-svg-lib", + "version": "1.0.0", + "source": { + "type": "git", + "url": "https://github.com/dompdf/php-svg-lib.git", + "reference": "eb045e518185298eb6ff8d80d0d0c6b17aecd9af" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/dompdf/php-svg-lib/zipball/eb045e518185298eb6ff8d80d0d0c6b17aecd9af", + "reference": "eb045e518185298eb6ff8d80d0d0c6b17aecd9af", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "php": "^7.1 || ^8.0", + "sabberworm/php-css-parser": "^8.4" + }, + "require-dev": { + "phpunit/phpunit": "^7.5 || ^8.5 || ^9.5" + }, + "type": "library", + "autoload": { + "psr-4": { + "Svg\\": "src/Svg" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-3.0-or-later" + ], + "authors": [ + { + "name": "The SvgLib Community", + "homepage": "https://github.com/dompdf/php-svg-lib/blob/master/AUTHORS.md" + } + ], + "description": "A library to read, parse and export to PDF SVG files.", + "homepage": "https://github.com/dompdf/php-svg-lib", + "support": { + "issues": "https://github.com/dompdf/php-svg-lib/issues", + "source": "https://github.com/dompdf/php-svg-lib/tree/1.0.0" + }, + "time": "2024-04-29T13:26:35+00:00" + }, + { + "name": "dragonmantank/cron-expression", + "version": "v3.4.0", + "source": { + "type": "git", + "url": "https://github.com/dragonmantank/cron-expression.git", + "reference": "8c784d071debd117328803d86b2097615b457500" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/dragonmantank/cron-expression/zipball/8c784d071debd117328803d86b2097615b457500", + "reference": "8c784d071debd117328803d86b2097615b457500", + "shasum": "" + }, + "require": { + "php": "^7.2|^8.0", + "webmozart/assert": "^1.0" + }, + "replace": { + "mtdowling/cron-expression": "^1.0" + }, + "require-dev": { + "phpstan/extension-installer": "^1.0", + "phpstan/phpstan": "^1.0", + "phpunit/phpunit": "^7.0|^8.0|^9.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Cron\\": "src/Cron/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Chris Tankersley", + "email": "chris@ctankersley.com", + "homepage": "https://github.com/dragonmantank" + } + ], + "description": "CRON for PHP: Calculate the next or previous run date and determine if a CRON expression is due", + "keywords": [ + "cron", + "schedule" + ], + "support": { + "issues": "https://github.com/dragonmantank/cron-expression/issues", + "source": "https://github.com/dragonmantank/cron-expression/tree/v3.4.0" + }, + "funding": [ + { + "url": "https://github.com/dragonmantank", + "type": "github" + } + ], + "time": "2024-10-09T13:47:03+00:00" + }, + { + "name": "egulias/email-validator", + "version": "4.0.4", + "source": { + "type": "git", + "url": "https://github.com/egulias/EmailValidator.git", + "reference": "d42c8731f0624ad6bdc8d3e5e9a4524f68801cfa" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/egulias/EmailValidator/zipball/d42c8731f0624ad6bdc8d3e5e9a4524f68801cfa", + "reference": "d42c8731f0624ad6bdc8d3e5e9a4524f68801cfa", + "shasum": "" + }, + "require": { + "doctrine/lexer": "^2.0 || ^3.0", + "php": ">=8.1", + "symfony/polyfill-intl-idn": "^1.26" + }, + "require-dev": { + "phpunit/phpunit": "^10.2", + "vimeo/psalm": "^5.12" + }, + "suggest": { + "ext-intl": "PHP Internationalization Libraries are required to use the SpoofChecking validation" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "4.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Egulias\\EmailValidator\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Eduardo Gulias Davis" + } + ], + "description": "A library for validating emails against several RFCs", + "homepage": "https://github.com/egulias/EmailValidator", + "keywords": [ + "email", + "emailvalidation", + "emailvalidator", + "validation", + "validator" + ], + "support": { + "issues": "https://github.com/egulias/EmailValidator/issues", + "source": "https://github.com/egulias/EmailValidator/tree/4.0.4" + }, + "funding": [ + { + "url": "https://github.com/egulias", + "type": "github" + } + ], + "time": "2025-03-06T22:45:56+00:00" + }, + { + "name": "endroid/qr-code", + "version": "5.1.0", + "source": { + "type": "git", + "url": "https://github.com/endroid/qr-code.git", + "reference": "393fec6c4cbdc1bd65570ac9d245704428010122" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/endroid/qr-code/zipball/393fec6c4cbdc1bd65570ac9d245704428010122", + "reference": "393fec6c4cbdc1bd65570ac9d245704428010122", + "shasum": "" + }, + "require": { + "bacon/bacon-qr-code": "^3.0", + "php": "^8.1" + }, + "require-dev": { + "endroid/quality": "dev-main", + "ext-gd": "*", + "khanamiryan/qrcode-detector-decoder": "^2.0.2", + "setasign/fpdf": "^1.8.2" + }, + "suggest": { + "ext-gd": "Enables you to write PNG images", + "khanamiryan/qrcode-detector-decoder": "Enables you to use the image validator", + "roave/security-advisories": "Makes sure package versions with known security issues are not installed", + "setasign/fpdf": "Enables you to use the PDF writer" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.x-dev" + } + }, + "autoload": { + "psr-4": { + "Endroid\\QrCode\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jeroen van den Enden", + "email": "info@endroid.nl" + } + ], + "description": "Endroid QR Code", + "homepage": "https://github.com/endroid/qr-code", + "keywords": [ + "code", + "endroid", + "php", + "qr", + "qrcode" + ], + "support": { + "issues": "https://github.com/endroid/qr-code/issues", + "source": "https://github.com/endroid/qr-code/tree/5.1.0" + }, + "funding": [ + { + "url": "https://github.com/endroid", + "type": "github" + } + ], + "time": "2024-09-08T08:52:55+00:00" + }, + { + "name": "facade/ignition-contracts", + "version": "1.0.2", + "source": { + "type": "git", + "url": "https://github.com/facade/ignition-contracts.git", + "reference": "3c921a1cdba35b68a7f0ccffc6dffc1995b18267" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/facade/ignition-contracts/zipball/3c921a1cdba35b68a7f0ccffc6dffc1995b18267", + "reference": "3c921a1cdba35b68a7f0ccffc6dffc1995b18267", + "shasum": "" + }, + "require": { + "php": "^7.3|^8.0" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^v2.15.8", + "phpunit/phpunit": "^9.3.11", + "vimeo/psalm": "^3.17.1" + }, + "type": "library", + "autoload": { + "psr-4": { + "Facade\\IgnitionContracts\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Freek Van der Herten", + "email": "freek@spatie.be", + "homepage": "https://flareapp.io", + "role": "Developer" + } + ], + "description": "Solution contracts for Ignition", + "homepage": "https://github.com/facade/ignition-contracts", + "keywords": [ + "contracts", + "flare", + "ignition" + ], + "support": { + "issues": "https://github.com/facade/ignition-contracts/issues", + "source": "https://github.com/facade/ignition-contracts/tree/1.0.2" + }, + "time": "2020-10-16T08:27:54+00:00" + }, + { + "name": "filament/actions", + "version": "v3.3.32", + "source": { + "type": "git", + "url": "https://github.com/filamentphp/actions.git", + "reference": "9eaddc610d9adc00d738b8b116cea1be35a88f85" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/filamentphp/actions/zipball/9eaddc610d9adc00d738b8b116cea1be35a88f85", + "reference": "9eaddc610d9adc00d738b8b116cea1be35a88f85", + "shasum": "" + }, + "require": { + "anourvalar/eloquent-serialize": "^1.2", + "filament/forms": "self.version", + "filament/infolists": "self.version", + "filament/notifications": "self.version", + "filament/support": "self.version", + "illuminate/contracts": "^10.45|^11.0|^12.0", + "illuminate/database": "^10.45|^11.0|^12.0", + "illuminate/support": "^10.45|^11.0|^12.0", + "league/csv": "^9.16", + "openspout/openspout": "^4.23", + "php": "^8.1", + "spatie/laravel-package-tools": "^1.9" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Filament\\Actions\\ActionsServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Filament\\Actions\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Easily add beautiful action modals to any Livewire component.", + "homepage": "https://github.com/filamentphp/filament", + "support": { + "issues": "https://github.com/filamentphp/filament/issues", + "source": "https://github.com/filamentphp/filament" + }, + "time": "2025-07-16T08:51:11+00:00" + }, + { + "name": "filament/filament", + "version": "v3.3.32", + "source": { + "type": "git", + "url": "https://github.com/filamentphp/panels.git", + "reference": "af470f710de176796a50b17433e1ef3a70676c88" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/filamentphp/panels/zipball/af470f710de176796a50b17433e1ef3a70676c88", + "reference": "af470f710de176796a50b17433e1ef3a70676c88", + "shasum": "" + }, + "require": { + "danharrin/livewire-rate-limiting": "^0.3|^1.0|^2.0", + "filament/actions": "self.version", + "filament/forms": "self.version", + "filament/infolists": "self.version", + "filament/notifications": "self.version", + "filament/support": "self.version", + "filament/tables": "self.version", + "filament/widgets": "self.version", + "illuminate/auth": "^10.45|^11.0|^12.0", + "illuminate/console": "^10.45|^11.0|^12.0", + "illuminate/contracts": "^10.45|^11.0|^12.0", + "illuminate/cookie": "^10.45|^11.0|^12.0", + "illuminate/database": "^10.45|^11.0|^12.0", + "illuminate/http": "^10.45|^11.0|^12.0", + "illuminate/routing": "^10.45|^11.0|^12.0", + "illuminate/session": "^10.45|^11.0|^12.0", + "illuminate/support": "^10.45|^11.0|^12.0", + "illuminate/view": "^10.45|^11.0|^12.0", + "php": "^8.1", + "spatie/laravel-package-tools": "^1.9" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Filament\\FilamentServiceProvider" + ] + } + }, + "autoload": { + "files": [ + "src/global_helpers.php", + "src/helpers.php" + ], + "psr-4": { + "Filament\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "A collection of full-stack components for accelerated Laravel app development.", + "homepage": "https://github.com/filamentphp/filament", + "support": { + "issues": "https://github.com/filamentphp/filament/issues", + "source": "https://github.com/filamentphp/filament" + }, + "time": "2025-07-16T08:51:29+00:00" + }, + { + "name": "filament/forms", + "version": "v3.3.32", + "source": { + "type": "git", + "url": "https://github.com/filamentphp/forms.git", + "reference": "65f81769261ba56a9eafa563ecb25fdfdedb742c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/filamentphp/forms/zipball/65f81769261ba56a9eafa563ecb25fdfdedb742c", + "reference": "65f81769261ba56a9eafa563ecb25fdfdedb742c", + "shasum": "" + }, + "require": { + "danharrin/date-format-converter": "^0.3", + "filament/actions": "self.version", + "filament/support": "self.version", + "illuminate/console": "^10.45|^11.0|^12.0", + "illuminate/contracts": "^10.45|^11.0|^12.0", + "illuminate/database": "^10.45|^11.0|^12.0", + "illuminate/filesystem": "^10.45|^11.0|^12.0", + "illuminate/support": "^10.45|^11.0|^12.0", + "illuminate/validation": "^10.45|^11.0|^12.0", + "illuminate/view": "^10.45|^11.0|^12.0", + "php": "^8.1", + "spatie/laravel-package-tools": "^1.9" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Filament\\Forms\\FormsServiceProvider" + ] + } + }, + "autoload": { + "files": [ + "src/helpers.php" + ], + "psr-4": { + "Filament\\Forms\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Easily add beautiful forms to any Livewire component.", + "homepage": "https://github.com/filamentphp/filament", + "support": { + "issues": "https://github.com/filamentphp/filament/issues", + "source": "https://github.com/filamentphp/filament" + }, + "time": "2025-07-16T08:51:37+00:00" + }, + { + "name": "filament/infolists", + "version": "v3.3.32", + "source": { + "type": "git", + "url": "https://github.com/filamentphp/infolists.git", + "reference": "89a3f1f236863e2035be3d7b0c68987508dd06fa" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/filamentphp/infolists/zipball/89a3f1f236863e2035be3d7b0c68987508dd06fa", + "reference": "89a3f1f236863e2035be3d7b0c68987508dd06fa", + "shasum": "" + }, + "require": { + "filament/actions": "self.version", + "filament/support": "self.version", + "illuminate/console": "^10.45|^11.0|^12.0", + "illuminate/contracts": "^10.45|^11.0|^12.0", + "illuminate/database": "^10.45|^11.0|^12.0", + "illuminate/filesystem": "^10.45|^11.0|^12.0", + "illuminate/support": "^10.45|^11.0|^12.0", + "illuminate/view": "^10.45|^11.0|^12.0", + "php": "^8.1", + "spatie/laravel-package-tools": "^1.9" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Filament\\Infolists\\InfolistsServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Filament\\Infolists\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Easily add beautiful read-only infolists to any Livewire component.", + "homepage": "https://github.com/filamentphp/filament", + "support": { + "issues": "https://github.com/filamentphp/filament/issues", + "source": "https://github.com/filamentphp/filament" + }, + "time": "2025-06-23T10:46:53+00:00" + }, + { + "name": "filament/notifications", + "version": "v3.3.32", + "source": { + "type": "git", + "url": "https://github.com/filamentphp/notifications.git", + "reference": "adc118c7fc34a423f3c01d6936ad0316f489949c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/filamentphp/notifications/zipball/adc118c7fc34a423f3c01d6936ad0316f489949c", + "reference": "adc118c7fc34a423f3c01d6936ad0316f489949c", + "shasum": "" + }, + "require": { + "filament/actions": "self.version", + "filament/support": "self.version", + "illuminate/contracts": "^10.45|^11.0|^12.0", + "illuminate/filesystem": "^10.45|^11.0|^12.0", + "illuminate/notifications": "^10.45|^11.0|^12.0", + "illuminate/support": "^10.45|^11.0|^12.0", + "php": "^8.1", + "spatie/laravel-package-tools": "^1.9" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Filament\\Notifications\\NotificationsServiceProvider" + ] + } + }, + "autoload": { + "files": [ + "src/Testing/Autoload.php" + ], + "psr-4": { + "Filament\\Notifications\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Easily add beautiful notifications to any Livewire app.", + "homepage": "https://github.com/filamentphp/filament", + "support": { + "issues": "https://github.com/filamentphp/filament/issues", + "source": "https://github.com/filamentphp/filament" + }, + "time": "2025-07-08T20:42:18+00:00" + }, + { + "name": "filament/support", + "version": "v3.3.32", + "source": { + "type": "git", + "url": "https://github.com/filamentphp/support.git", + "reference": "0bf4856840e160624ba79f43aaaa3ec396140f1d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/filamentphp/support/zipball/0bf4856840e160624ba79f43aaaa3ec396140f1d", + "reference": "0bf4856840e160624ba79f43aaaa3ec396140f1d", + "shasum": "" + }, + "require": { + "blade-ui-kit/blade-heroicons": "^2.5", + "doctrine/dbal": "^3.2|^4.0", + "ext-intl": "*", + "illuminate/contracts": "^10.45|^11.0|^12.0", + "illuminate/support": "^10.45|^11.0|^12.0", + "illuminate/view": "^10.45|^11.0|^12.0", + "kirschbaum-development/eloquent-power-joins": "^3.0|^4.0", + "livewire/livewire": "^3.5", + "php": "^8.1", + "ryangjchandler/blade-capture-directive": "^0.2|^0.3|^1.0", + "spatie/color": "^1.5", + "spatie/invade": "^1.0|^2.0", + "spatie/laravel-package-tools": "^1.9", + "symfony/console": "^6.0|^7.0", + "symfony/html-sanitizer": "^6.1|^7.0" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Filament\\Support\\SupportServiceProvider" + ] + } + }, + "autoload": { + "files": [ + "src/helpers.php" + ], + "psr-4": { + "Filament\\Support\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Core helper methods and foundation code for all Filament packages.", + "homepage": "https://github.com/filamentphp/filament", + "support": { + "issues": "https://github.com/filamentphp/filament/issues", + "source": "https://github.com/filamentphp/filament" + }, + "time": "2025-07-16T08:51:22+00:00" + }, + { + "name": "filament/tables", + "version": "v3.3.32", + "source": { + "type": "git", + "url": "https://github.com/filamentphp/tables.git", + "reference": "3f0d827c960f1ee4a67ab71c416ad67e24747dc4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/filamentphp/tables/zipball/3f0d827c960f1ee4a67ab71c416ad67e24747dc4", + "reference": "3f0d827c960f1ee4a67ab71c416ad67e24747dc4", + "shasum": "" + }, + "require": { + "filament/actions": "self.version", + "filament/forms": "self.version", + "filament/support": "self.version", + "illuminate/console": "^10.45|^11.0|^12.0", + "illuminate/contracts": "^10.45|^11.0|^12.0", + "illuminate/database": "^10.45|^11.0|^12.0", + "illuminate/filesystem": "^10.45|^11.0|^12.0", + "illuminate/support": "^10.45|^11.0|^12.0", + "illuminate/view": "^10.45|^11.0|^12.0", + "php": "^8.1", + "spatie/laravel-package-tools": "^1.9" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Filament\\Tables\\TablesServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Filament\\Tables\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Easily add beautiful tables to any Livewire component.", + "homepage": "https://github.com/filamentphp/filament", + "support": { + "issues": "https://github.com/filamentphp/filament/issues", + "source": "https://github.com/filamentphp/filament" + }, + "time": "2025-07-08T20:42:18+00:00" + }, + { + "name": "filament/widgets", + "version": "v3.3.32", + "source": { + "type": "git", + "url": "https://github.com/filamentphp/widgets.git", + "reference": "5b956f884aaef479f6091463cb829e7c9f2afc2c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/filamentphp/widgets/zipball/5b956f884aaef479f6091463cb829e7c9f2afc2c", + "reference": "5b956f884aaef479f6091463cb829e7c9f2afc2c", + "shasum": "" + }, + "require": { + "filament/support": "self.version", + "php": "^8.1", + "spatie/laravel-package-tools": "^1.9" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Filament\\Widgets\\WidgetsServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Filament\\Widgets\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Easily add beautiful dashboard widgets to any Livewire component.", + "homepage": "https://github.com/filamentphp/filament", + "support": { + "issues": "https://github.com/filamentphp/filament/issues", + "source": "https://github.com/filamentphp/filament" + }, + "time": "2025-06-12T15:11:14+00:00" + }, + { + "name": "firebase/php-jwt", + "version": "v6.11.1", + "source": { + "type": "git", + "url": "https://github.com/firebase/php-jwt.git", + "reference": "d1e91ecf8c598d073d0995afa8cd5c75c6e19e66" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/firebase/php-jwt/zipball/d1e91ecf8c598d073d0995afa8cd5c75c6e19e66", + "reference": "d1e91ecf8c598d073d0995afa8cd5c75c6e19e66", + "shasum": "" + }, + "require": { + "php": "^8.0" + }, + "require-dev": { + "guzzlehttp/guzzle": "^7.4", + "phpspec/prophecy-phpunit": "^2.0", + "phpunit/phpunit": "^9.5", + "psr/cache": "^2.0||^3.0", + "psr/http-client": "^1.0", + "psr/http-factory": "^1.0" + }, + "suggest": { + "ext-sodium": "Support EdDSA (Ed25519) signatures", + "paragonie/sodium_compat": "Support EdDSA (Ed25519) signatures when libsodium is not present" + }, + "type": "library", + "autoload": { + "psr-4": { + "Firebase\\JWT\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Neuman Vong", + "email": "neuman+pear@twilio.com", + "role": "Developer" + }, + { + "name": "Anant Narayanan", + "email": "anant@php.net", + "role": "Developer" + } + ], + "description": "A simple library to encode and decode JSON Web Tokens (JWT) in PHP. Should conform to the current spec.", + "homepage": "https://github.com/firebase/php-jwt", + "keywords": [ + "jwt", + "php" + ], + "support": { + "issues": "https://github.com/firebase/php-jwt/issues", + "source": "https://github.com/firebase/php-jwt/tree/v6.11.1" + }, + "time": "2025-04-09T20:32:01+00:00" + }, + { + "name": "flowframe/laravel-trend", + "version": "v0.4.0", + "source": { + "type": "git", + "url": "https://github.com/Flowframe/laravel-trend.git", + "reference": "5ace11d3075932652dc48963faa732c043aeb14d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Flowframe/laravel-trend/zipball/5ace11d3075932652dc48963faa732c043aeb14d", + "reference": "5ace11d3075932652dc48963faa732c043aeb14d", + "shasum": "" + }, + "require": { + "illuminate/contracts": "^8.37|^9|^10.0|^11.0|^12.0", + "php": "^8.2", + "spatie/laravel-package-tools": "^1.4.3" + }, + "require-dev": { + "nunomaduro/collision": "^5.3|^6.1|^8.0", + "orchestra/testbench": "^6.15|^7.0|^8.0|^9.0|^10.0", + "pestphp/pest": "^1.18|^2.34|^3.7", + "pestphp/pest-plugin-laravel": "^1.1|^2.3|^3.1", + "spatie/laravel-ray": "^1.23", + "vimeo/psalm": "^4.8|^5.6|^6.5" + }, + "type": "library", + "extra": { + "laravel": { + "aliases": { + "Trend": "Flowframe\\Trend\\TrendFacade" + }, + "providers": [ + "Flowframe\\Trend\\TrendServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Flowframe\\Trend\\": "src", + "Flowframe\\Trend\\Database\\Factories\\": "database/factories" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Lars Klopstra", + "email": "lars@flowframe.nl", + "role": "Developer" + } + ], + "description": "Easily generate model trends", + "homepage": "https://github.com/flowframe/laravel-trend", + "keywords": [ + "Flowframe", + "laravel", + "laravel-trend" + ], + "support": { + "issues": "https://github.com/Flowframe/laravel-trend/issues", + "source": "https://github.com/Flowframe/laravel-trend/tree/v0.4.0" + }, + "funding": [ + { + "url": "https://github.com/larsklopstra", + "type": "github" + } + ], + "time": "2025-02-25T11:13:23+00:00" + }, + { + "name": "fruitcake/php-cors", + "version": "v1.3.0", + "source": { + "type": "git", + "url": "https://github.com/fruitcake/php-cors.git", + "reference": "3d158f36e7875e2f040f37bc0573956240a5a38b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/fruitcake/php-cors/zipball/3d158f36e7875e2f040f37bc0573956240a5a38b", + "reference": "3d158f36e7875e2f040f37bc0573956240a5a38b", + "shasum": "" + }, + "require": { + "php": "^7.4|^8.0", + "symfony/http-foundation": "^4.4|^5.4|^6|^7" + }, + "require-dev": { + "phpstan/phpstan": "^1.4", + "phpunit/phpunit": "^9", + "squizlabs/php_codesniffer": "^3.5" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.2-dev" + } + }, + "autoload": { + "psr-4": { + "Fruitcake\\Cors\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fruitcake", + "homepage": "https://fruitcake.nl" + }, + { + "name": "Barryvdh", + "email": "barryvdh@gmail.com" + } + ], + "description": "Cross-origin resource sharing library for the Symfony HttpFoundation", + "homepage": "https://github.com/fruitcake/php-cors", + "keywords": [ + "cors", + "laravel", + "symfony" + ], + "support": { + "issues": "https://github.com/fruitcake/php-cors/issues", + "source": "https://github.com/fruitcake/php-cors/tree/v1.3.0" + }, + "funding": [ + { + "url": "https://fruitcake.nl", + "type": "custom" + }, + { + "url": "https://github.com/barryvdh", + "type": "github" + } + ], + "time": "2023-10-12T05:21:21+00:00" + }, + { + "name": "graham-campbell/result-type", + "version": "v1.1.3", + "source": { + "type": "git", + "url": "https://github.com/GrahamCampbell/Result-Type.git", + "reference": "3ba905c11371512af9d9bdd27d99b782216b6945" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/GrahamCampbell/Result-Type/zipball/3ba905c11371512af9d9bdd27d99b782216b6945", + "reference": "3ba905c11371512af9d9bdd27d99b782216b6945", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0", + "phpoption/phpoption": "^1.9.3" + }, + "require-dev": { + "phpunit/phpunit": "^8.5.39 || ^9.6.20 || ^10.5.28" + }, + "type": "library", + "autoload": { + "psr-4": { + "GrahamCampbell\\ResultType\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + } + ], + "description": "An Implementation Of The Result Type", + "keywords": [ + "Graham Campbell", + "GrahamCampbell", + "Result Type", + "Result-Type", + "result" + ], + "support": { + "issues": "https://github.com/GrahamCampbell/Result-Type/issues", + "source": "https://github.com/GrahamCampbell/Result-Type/tree/v1.1.3" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/graham-campbell/result-type", + "type": "tidelift" + } + ], + "time": "2024-07-20T21:45:45+00:00" + }, + { + "name": "guzzlehttp/guzzle", + "version": "7.9.3", + "source": { + "type": "git", + "url": "https://github.com/guzzle/guzzle.git", + "reference": "7b2f29fe81dc4da0ca0ea7d42107a0845946ea77" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/guzzle/zipball/7b2f29fe81dc4da0ca0ea7d42107a0845946ea77", + "reference": "7b2f29fe81dc4da0ca0ea7d42107a0845946ea77", + "shasum": "" + }, + "require": { + "ext-json": "*", + "guzzlehttp/promises": "^1.5.3 || ^2.0.3", + "guzzlehttp/psr7": "^2.7.0", + "php": "^7.2.5 || ^8.0", + "psr/http-client": "^1.0", + "symfony/deprecation-contracts": "^2.2 || ^3.0" + }, + "provide": { + "psr/http-client-implementation": "1.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "ext-curl": "*", + "guzzle/client-integration-tests": "3.0.2", + "php-http/message-factory": "^1.1", + "phpunit/phpunit": "^8.5.39 || ^9.6.20", + "psr/log": "^1.1 || ^2.0 || ^3.0" + }, + "suggest": { + "ext-curl": "Required for CURL handler support", + "ext-intl": "Required for Internationalized Domain Name (IDN) support", + "psr/log": "Required for using the Log middleware" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + } + }, + "autoload": { + "files": [ + "src/functions_include.php" + ], + "psr-4": { + "GuzzleHttp\\": "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" + }, + { + "name": "Jeremy Lindblom", + "email": "jeremeamia@gmail.com", + "homepage": "https://github.com/jeremeamia" + }, + { + "name": "George Mponos", + "email": "gmponos@gmail.com", + "homepage": "https://github.com/gmponos" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com", + "homepage": "https://github.com/sagikazarmark" + }, + { + "name": "Tobias Schultze", + "email": "webmaster@tubo-world.de", + "homepage": "https://github.com/Tobion" + } + ], + "description": "Guzzle is a PHP HTTP client library", + "keywords": [ + "client", + "curl", + "framework", + "http", + "http client", + "psr-18", + "psr-7", + "rest", + "web service" + ], + "support": { + "issues": "https://github.com/guzzle/guzzle/issues", + "source": "https://github.com/guzzle/guzzle/tree/7.9.3" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/guzzle", + "type": "tidelift" + } + ], + "time": "2025-03-27T13:37:11+00:00" + }, + { + "name": "guzzlehttp/promises", + "version": "2.2.0", + "source": { + "type": "git", + "url": "https://github.com/guzzle/promises.git", + "reference": "7c69f28996b0a6920945dd20b3857e499d9ca96c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/promises/zipball/7c69f28996b0a6920945dd20b3857e499d9ca96c", + "reference": "7c69f28996b0a6920945dd20b3857e499d9ca96c", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "phpunit/phpunit": "^8.5.39 || ^9.6.20" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + } + }, + "autoload": { + "psr-4": { + "GuzzleHttp\\Promise\\": "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" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + }, + { + "name": "Tobias Schultze", + "email": "webmaster@tubo-world.de", + "homepage": "https://github.com/Tobion" + } + ], + "description": "Guzzle promises library", + "keywords": [ + "promise" + ], + "support": { + "issues": "https://github.com/guzzle/promises/issues", + "source": "https://github.com/guzzle/promises/tree/2.2.0" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/promises", + "type": "tidelift" + } + ], + "time": "2025-03-27T13:27:01+00:00" + }, + { + "name": "guzzlehttp/psr7", + "version": "2.7.1", + "source": { + "type": "git", + "url": "https://github.com/guzzle/psr7.git", + "reference": "c2270caaabe631b3b44c85f99e5a04bbb8060d16" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/psr7/zipball/c2270caaabe631b3b44c85f99e5a04bbb8060d16", + "reference": "c2270caaabe631b3b44c85f99e5a04bbb8060d16", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0", + "psr/http-factory": "^1.0", + "psr/http-message": "^1.1 || ^2.0", + "ralouphie/getallheaders": "^3.0" + }, + "provide": { + "psr/http-factory-implementation": "1.0", + "psr/http-message-implementation": "1.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "http-interop/http-factory-tests": "0.9.0", + "phpunit/phpunit": "^8.5.39 || ^9.6.20" + }, + "suggest": { + "laminas/laminas-httphandlerrunner": "Emit PSR-7 responses" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + } + }, + "autoload": { + "psr-4": { + "GuzzleHttp\\Psr7\\": "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" + }, + { + "name": "George Mponos", + "email": "gmponos@gmail.com", + "homepage": "https://github.com/gmponos" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com", + "homepage": "https://github.com/sagikazarmark" + }, + { + "name": "Tobias Schultze", + "email": "webmaster@tubo-world.de", + "homepage": "https://github.com/Tobion" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com", + "homepage": "https://sagikazarmark.hu" + } + ], + "description": "PSR-7 message implementation that also provides common utility methods", + "keywords": [ + "http", + "message", + "psr-7", + "request", + "response", + "stream", + "uri", + "url" + ], + "support": { + "issues": "https://github.com/guzzle/psr7/issues", + "source": "https://github.com/guzzle/psr7/tree/2.7.1" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/psr7", + "type": "tidelift" + } + ], + "time": "2025-03-27T12:30:47+00:00" + }, + { + "name": "guzzlehttp/uri-template", + "version": "v1.0.4", + "source": { + "type": "git", + "url": "https://github.com/guzzle/uri-template.git", + "reference": "30e286560c137526eccd4ce21b2de477ab0676d2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/uri-template/zipball/30e286560c137526eccd4ce21b2de477ab0676d2", + "reference": "30e286560c137526eccd4ce21b2de477ab0676d2", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0", + "symfony/polyfill-php80": "^1.24" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "phpunit/phpunit": "^8.5.36 || ^9.6.15", + "uri-template/tests": "1.0.0" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + } + }, + "autoload": { + "psr-4": { + "GuzzleHttp\\UriTemplate\\": "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" + }, + { + "name": "George Mponos", + "email": "gmponos@gmail.com", + "homepage": "https://github.com/gmponos" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + } + ], + "description": "A polyfill class for uri_template of PHP", + "keywords": [ + "guzzlehttp", + "uri-template" + ], + "support": { + "issues": "https://github.com/guzzle/uri-template/issues", + "source": "https://github.com/guzzle/uri-template/tree/v1.0.4" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/uri-template", + "type": "tidelift" + } + ], + "time": "2025-02-03T10:55:03+00:00" + }, + { + "name": "kirschbaum-development/eloquent-power-joins", + "version": "4.2.6", + "source": { + "type": "git", + "url": "https://github.com/kirschbaum-development/eloquent-power-joins.git", + "reference": "72cff1e838bb3f826dc09a5566219ad7fa56237f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/kirschbaum-development/eloquent-power-joins/zipball/72cff1e838bb3f826dc09a5566219ad7fa56237f", + "reference": "72cff1e838bb3f826dc09a5566219ad7fa56237f", + "shasum": "" + }, + "require": { + "illuminate/database": "^11.42|^12.0", + "illuminate/support": "^11.42|^12.0", + "php": "^8.2" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "dev-master", + "laravel/legacy-factories": "^1.0@dev", + "orchestra/testbench": "^9.0|^10.0", + "phpunit/phpunit": "^10.0|^11.0" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Kirschbaum\\PowerJoins\\PowerJoinsServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Kirschbaum\\PowerJoins\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Luis Dalmolin", + "email": "luis.nh@gmail.com", + "role": "Developer" + } + ], + "description": "The Laravel magic applied to joins.", + "homepage": "https://github.com/kirschbaum-development/eloquent-power-joins", + "keywords": [ + "eloquent", + "join", + "laravel", + "mysql" + ], + "support": { + "issues": "https://github.com/kirschbaum-development/eloquent-power-joins/issues", + "source": "https://github.com/kirschbaum-development/eloquent-power-joins/tree/4.2.6" + }, + "time": "2025-07-10T16:55:34+00:00" + }, + { + "name": "laminas/laminas-diactoros", + "version": "3.8.0", + "source": { + "type": "git", + "url": "https://github.com/laminas/laminas-diactoros.git", + "reference": "60c182916b2749480895601649563970f3f12ec4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laminas/laminas-diactoros/zipball/60c182916b2749480895601649563970f3f12ec4", + "reference": "60c182916b2749480895601649563970f3f12ec4", + "shasum": "" + }, + "require": { + "php": "~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0", + "psr/http-factory": "^1.1", + "psr/http-message": "^1.1 || ^2.0" + }, + "conflict": { + "amphp/amp": "<2.6.4" + }, + "provide": { + "psr/http-factory-implementation": "^1.0", + "psr/http-message-implementation": "^1.1 || ^2.0" + }, + "require-dev": { + "ext-curl": "*", + "ext-dom": "*", + "ext-gd": "*", + "ext-libxml": "*", + "http-interop/http-factory-tests": "^2.2.0", + "laminas/laminas-coding-standard": "~3.1.0", + "php-http/psr7-integration-tests": "^1.4.0", + "phpunit/phpunit": "^10.5.36", + "psalm/plugin-phpunit": "^0.19.5", + "vimeo/psalm": "^6.13" + }, + "type": "library", + "extra": { + "laminas": { + "module": "Laminas\\Diactoros", + "config-provider": "Laminas\\Diactoros\\ConfigProvider" + } + }, + "autoload": { + "files": [ + "src/functions/create_uploaded_file.php", + "src/functions/marshal_headers_from_sapi.php", + "src/functions/marshal_method_from_sapi.php", + "src/functions/marshal_protocol_version_from_sapi.php", + "src/functions/normalize_server.php", + "src/functions/normalize_uploaded_files.php", + "src/functions/parse_cookie_header.php" + ], + "psr-4": { + "Laminas\\Diactoros\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "description": "PSR HTTP Message implementations", + "homepage": "https://laminas.dev", + "keywords": [ + "http", + "laminas", + "psr", + "psr-17", + "psr-7" + ], + "support": { + "chat": "https://laminas.dev/chat", + "docs": "https://docs.laminas.dev/laminas-diactoros/", + "forum": "https://discourse.laminas.dev", + "issues": "https://github.com/laminas/laminas-diactoros/issues", + "rss": "https://github.com/laminas/laminas-diactoros/releases.atom", + "source": "https://github.com/laminas/laminas-diactoros" + }, + "funding": [ + { + "url": "https://funding.communitybridge.org/projects/laminas-project", + "type": "community_bridge" + } + ], + "time": "2025-10-12T15:31:36+00:00" + }, + { + "name": "laravel/framework", + "version": "v12.20.0", + "source": { + "type": "git", + "url": "https://github.com/laravel/framework.git", + "reference": "1b9a00f8caf5503c92aa436279172beae1a484ff" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/framework/zipball/1b9a00f8caf5503c92aa436279172beae1a484ff", + "reference": "1b9a00f8caf5503c92aa436279172beae1a484ff", + "shasum": "" + }, + "require": { + "brick/math": "^0.11|^0.12|^0.13", + "composer-runtime-api": "^2.2", + "doctrine/inflector": "^2.0.5", + "dragonmantank/cron-expression": "^3.4", + "egulias/email-validator": "^3.2.1|^4.0", + "ext-ctype": "*", + "ext-filter": "*", + "ext-hash": "*", + "ext-mbstring": "*", + "ext-openssl": "*", + "ext-session": "*", + "ext-tokenizer": "*", + "fruitcake/php-cors": "^1.3", + "guzzlehttp/guzzle": "^7.8.2", + "guzzlehttp/uri-template": "^1.0", + "laravel/prompts": "^0.3.0", + "laravel/serializable-closure": "^1.3|^2.0", + "league/commonmark": "^2.7", + "league/flysystem": "^3.25.1", + "league/flysystem-local": "^3.25.1", + "league/uri": "^7.5.1", + "monolog/monolog": "^3.0", + "nesbot/carbon": "^3.8.4", + "nunomaduro/termwind": "^2.0", + "php": "^8.2", + "psr/container": "^1.1.1|^2.0.1", + "psr/log": "^1.0|^2.0|^3.0", + "psr/simple-cache": "^1.0|^2.0|^3.0", + "ramsey/uuid": "^4.7", + "symfony/console": "^7.2.0", + "symfony/error-handler": "^7.2.0", + "symfony/finder": "^7.2.0", + "symfony/http-foundation": "^7.2.0", + "symfony/http-kernel": "^7.2.0", + "symfony/mailer": "^7.2.0", + "symfony/mime": "^7.2.0", + "symfony/polyfill-php83": "^1.31", + "symfony/process": "^7.2.0", + "symfony/routing": "^7.2.0", + "symfony/uid": "^7.2.0", + "symfony/var-dumper": "^7.2.0", + "tijsverkoyen/css-to-inline-styles": "^2.2.5", + "vlucas/phpdotenv": "^5.6.1", + "voku/portable-ascii": "^2.0.2" + }, + "conflict": { + "tightenco/collect": "<5.5.33" + }, + "provide": { + "psr/container-implementation": "1.1|2.0", + "psr/log-implementation": "1.0|2.0|3.0", + "psr/simple-cache-implementation": "1.0|2.0|3.0" + }, + "replace": { + "illuminate/auth": "self.version", + "illuminate/broadcasting": "self.version", + "illuminate/bus": "self.version", + "illuminate/cache": "self.version", + "illuminate/collections": "self.version", + "illuminate/concurrency": "self.version", + "illuminate/conditionable": "self.version", + "illuminate/config": "self.version", + "illuminate/console": "self.version", + "illuminate/container": "self.version", + "illuminate/contracts": "self.version", + "illuminate/cookie": "self.version", + "illuminate/database": "self.version", + "illuminate/encryption": "self.version", + "illuminate/events": "self.version", + "illuminate/filesystem": "self.version", + "illuminate/hashing": "self.version", + "illuminate/http": "self.version", + "illuminate/log": "self.version", + "illuminate/macroable": "self.version", + "illuminate/mail": "self.version", + "illuminate/notifications": "self.version", + "illuminate/pagination": "self.version", + "illuminate/pipeline": "self.version", + "illuminate/process": "self.version", + "illuminate/queue": "self.version", + "illuminate/redis": "self.version", + "illuminate/routing": "self.version", + "illuminate/session": "self.version", + "illuminate/support": "self.version", + "illuminate/testing": "self.version", + "illuminate/translation": "self.version", + "illuminate/validation": "self.version", + "illuminate/view": "self.version", + "spatie/once": "*" + }, + "require-dev": { + "ably/ably-php": "^1.0", + "aws/aws-sdk-php": "^3.322.9", + "ext-gmp": "*", + "fakerphp/faker": "^1.24", + "guzzlehttp/promises": "^2.0.3", + "guzzlehttp/psr7": "^2.4", + "laravel/pint": "^1.18", + "league/flysystem-aws-s3-v3": "^3.25.1", + "league/flysystem-ftp": "^3.25.1", + "league/flysystem-path-prefixing": "^3.25.1", + "league/flysystem-read-only": "^3.25.1", + "league/flysystem-sftp-v3": "^3.25.1", + "mockery/mockery": "^1.6.10", + "orchestra/testbench-core": "^10.0.0", + "pda/pheanstalk": "^5.0.6|^7.0.0", + "php-http/discovery": "^1.15", + "phpstan/phpstan": "^2.0", + "phpunit/phpunit": "^10.5.35|^11.5.3|^12.0.1", + "predis/predis": "^2.3|^3.0", + "resend/resend-php": "^0.10.0", + "symfony/cache": "^7.2.0", + "symfony/http-client": "^7.2.0", + "symfony/psr-http-message-bridge": "^7.2.0", + "symfony/translation": "^7.2.0" + }, + "suggest": { + "ably/ably-php": "Required to use the Ably broadcast driver (^1.0).", + "aws/aws-sdk-php": "Required to use the SQS queue driver, DynamoDb failed job storage, and SES mail driver (^3.322.9).", + "brianium/paratest": "Required to run tests in parallel (^7.0|^8.0).", + "ext-apcu": "Required to use the APC cache driver.", + "ext-fileinfo": "Required to use the Filesystem class.", + "ext-ftp": "Required to use the Flysystem FTP driver.", + "ext-gd": "Required to use Illuminate\\Http\\Testing\\FileFactory::image().", + "ext-memcached": "Required to use the memcache cache driver.", + "ext-pcntl": "Required to use all features of the queue worker and console signal trapping.", + "ext-pdo": "Required to use all database features.", + "ext-posix": "Required to use all features of the queue worker.", + "ext-redis": "Required to use the Redis cache and queue drivers (^4.0|^5.0|^6.0).", + "fakerphp/faker": "Required to use the eloquent factory builder (^1.9.1).", + "filp/whoops": "Required for friendly error pages in development (^2.14.3).", + "laravel/tinker": "Required to use the tinker console command (^2.0).", + "league/flysystem-aws-s3-v3": "Required to use the Flysystem S3 driver (^3.25.1).", + "league/flysystem-ftp": "Required to use the Flysystem FTP driver (^3.25.1).", + "league/flysystem-path-prefixing": "Required to use the scoped driver (^3.25.1).", + "league/flysystem-read-only": "Required to use read-only disks (^3.25.1)", + "league/flysystem-sftp-v3": "Required to use the Flysystem SFTP driver (^3.25.1).", + "mockery/mockery": "Required to use mocking (^1.6).", + "pda/pheanstalk": "Required to use the beanstalk queue driver (^5.0).", + "php-http/discovery": "Required to use PSR-7 bridging features (^1.15).", + "phpunit/phpunit": "Required to use assertions and run tests (^10.5.35|^11.5.3|^12.0.1).", + "predis/predis": "Required to use the predis connector (^2.3|^3.0).", + "psr/http-message": "Required to allow Storage::put to accept a StreamInterface (^1.0).", + "pusher/pusher-php-server": "Required to use the Pusher broadcast driver (^6.0|^7.0).", + "resend/resend-php": "Required to enable support for the Resend mail transport (^0.10.0).", + "symfony/cache": "Required to PSR-6 cache bridge (^7.2).", + "symfony/filesystem": "Required to enable support for relative symbolic links (^7.2).", + "symfony/http-client": "Required to enable support for the Symfony API mail transports (^7.2).", + "symfony/mailgun-mailer": "Required to enable support for the Mailgun mail transport (^7.2).", + "symfony/postmark-mailer": "Required to enable support for the Postmark mail transport (^7.2).", + "symfony/psr-http-message-bridge": "Required to use PSR-7 bridging features (^7.2)." + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "12.x-dev" + } + }, + "autoload": { + "files": [ + "src/Illuminate/Collections/functions.php", + "src/Illuminate/Collections/helpers.php", + "src/Illuminate/Events/functions.php", + "src/Illuminate/Filesystem/functions.php", + "src/Illuminate/Foundation/helpers.php", + "src/Illuminate/Log/functions.php", + "src/Illuminate/Support/functions.php", + "src/Illuminate/Support/helpers.php" + ], + "psr-4": { + "Illuminate\\": "src/Illuminate/", + "Illuminate\\Support\\": [ + "src/Illuminate/Macroable/", + "src/Illuminate/Collections/", + "src/Illuminate/Conditionable/" + ] + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "The Laravel Framework.", + "homepage": "https://laravel.com", + "keywords": [ + "framework", + "laravel" + ], + "support": { + "issues": "https://github.com/laravel/framework/issues", + "source": "https://github.com/laravel/framework" + }, + "time": "2025-07-08T15:02:21+00:00" + }, + { + "name": "laravel/octane", + "version": "v2.13.0", + "source": { + "type": "git", + "url": "https://github.com/laravel/octane.git", + "reference": "21fd6220560c609bf703fa4ce6008081a4ce746c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/octane/zipball/21fd6220560c609bf703fa4ce6008081a4ce746c", + "reference": "21fd6220560c609bf703fa4ce6008081a4ce746c", + "shasum": "" + }, + "require": { + "laminas/laminas-diactoros": "^3.0", + "laravel/framework": "^10.10.1|^11.0|^12.0", + "laravel/prompts": "^0.1.24|^0.2.0|^0.3.0", + "laravel/serializable-closure": "^1.3|^2.0", + "nesbot/carbon": "^2.66.0|^3.0", + "php": "^8.1.0", + "symfony/console": "^6.0|^7.0", + "symfony/psr-http-message-bridge": "^2.2.0|^6.4|^7.0" + }, + "conflict": { + "spiral/roadrunner": "<2023.1.0", + "spiral/roadrunner-cli": "<2.6.0", + "spiral/roadrunner-http": "<3.3.0" + }, + "require-dev": { + "guzzlehttp/guzzle": "^7.6.1", + "inertiajs/inertia-laravel": "^1.3.2|^2.0", + "laravel/scout": "^10.2.1", + "laravel/socialite": "^5.6.1", + "livewire/livewire": "^2.12.3|^3.0", + "mockery/mockery": "^1.5.1", + "nunomaduro/collision": "^6.4.0|^7.5.2|^8.0", + "orchestra/testbench": "^8.21|^9.0|^10.0", + "phpstan/phpstan": "^2.1.7", + "phpunit/phpunit": "^10.4|^11.5", + "spiral/roadrunner-cli": "^2.6.0", + "spiral/roadrunner-http": "^3.3.0" + }, + "bin": [ + "bin/roadrunner-worker", + "bin/swoole-server" + ], + "type": "library", + "extra": { + "laravel": { + "aliases": { + "Octane": "Laravel\\Octane\\Facades\\Octane" + }, + "providers": [ + "Laravel\\Octane\\OctaneServiceProvider" + ] + }, + "branch-alias": { + "dev-master": "2.x-dev" + } + }, + "autoload": { + "psr-4": { + "Laravel\\Octane\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "Supercharge your Laravel application's performance.", + "keywords": [ + "frankenphp", + "laravel", + "octane", + "roadrunner", + "swoole" + ], + "support": { + "issues": "https://github.com/laravel/octane/issues", + "source": "https://github.com/laravel/octane" + }, + "time": "2025-10-13T16:28:21+00:00" + }, + { + "name": "laravel/passport", + "version": "v12.4.2", + "source": { + "type": "git", + "url": "https://github.com/laravel/passport.git", + "reference": "65a885607b62d361aedaeb10a946bc6b5a954262" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/passport/zipball/65a885607b62d361aedaeb10a946bc6b5a954262", + "reference": "65a885607b62d361aedaeb10a946bc6b5a954262", + "shasum": "" + }, + "require": { + "ext-json": "*", + "firebase/php-jwt": "^6.4", + "illuminate/auth": "^9.21|^10.0|^11.0|^12.0", + "illuminate/console": "^9.21|^10.0|^11.0|^12.0", + "illuminate/container": "^9.21|^10.0|^11.0|^12.0", + "illuminate/contracts": "^9.21|^10.0|^11.0|^12.0", + "illuminate/cookie": "^9.21|^10.0|^11.0|^12.0", + "illuminate/database": "^9.21|^10.0|^11.0|^12.0", + "illuminate/encryption": "^9.21|^10.0|^11.0|^12.0", + "illuminate/http": "^9.21|^10.0|^11.0|^12.0", + "illuminate/support": "^9.21|^10.0|^11.0|^12.0", + "lcobucci/jwt": "^4.3|^5.0", + "league/oauth2-server": "^8.5.3", + "nyholm/psr7": "^1.5", + "php": "^8.0", + "phpseclib/phpseclib": "^2.0|^3.0", + "symfony/console": "^6.0|^7.0", + "symfony/psr-http-message-bridge": "^2.1|^6.0|^7.0" + }, + "require-dev": { + "mockery/mockery": "^1.0", + "orchestra/testbench": "^7.35|^8.14|^9.0|^10.0", + "phpstan/phpstan": "^1.10", + "phpunit/phpunit": "^9.3|^10.5|^11.5" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Laravel\\Passport\\PassportServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Laravel\\Passport\\": "src/", + "Laravel\\Passport\\Database\\Factories\\": "database/factories/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "Laravel Passport provides OAuth2 server support to Laravel.", + "keywords": [ + "laravel", + "oauth", + "passport" + ], + "support": { + "issues": "https://github.com/laravel/passport/issues", + "source": "https://github.com/laravel/passport" + }, + "time": "2025-02-12T16:11:33+00:00" + }, + { + "name": "laravel/prompts", + "version": "v0.3.6", + "source": { + "type": "git", + "url": "https://github.com/laravel/prompts.git", + "reference": "86a8b692e8661d0fb308cec64f3d176821323077" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/prompts/zipball/86a8b692e8661d0fb308cec64f3d176821323077", + "reference": "86a8b692e8661d0fb308cec64f3d176821323077", + "shasum": "" + }, + "require": { + "composer-runtime-api": "^2.2", + "ext-mbstring": "*", + "php": "^8.1", + "symfony/console": "^6.2|^7.0" + }, + "conflict": { + "illuminate/console": ">=10.17.0 <10.25.0", + "laravel/framework": ">=10.17.0 <10.25.0" + }, + "require-dev": { + "illuminate/collections": "^10.0|^11.0|^12.0", + "mockery/mockery": "^1.5", + "pestphp/pest": "^2.3|^3.4", + "phpstan/phpstan": "^1.11", + "phpstan/phpstan-mockery": "^1.1" + }, + "suggest": { + "ext-pcntl": "Required for the spinner to be animated." + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "0.3.x-dev" + } + }, + "autoload": { + "files": [ + "src/helpers.php" + ], + "psr-4": { + "Laravel\\Prompts\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Add beautiful and user-friendly forms to your command-line applications.", + "support": { + "issues": "https://github.com/laravel/prompts/issues", + "source": "https://github.com/laravel/prompts/tree/v0.3.6" + }, + "time": "2025-07-07T14:17:42+00:00" + }, + { + "name": "laravel/serializable-closure", + "version": "v2.0.4", + "source": { + "type": "git", + "url": "https://github.com/laravel/serializable-closure.git", + "reference": "b352cf0534aa1ae6b4d825d1e762e35d43f8a841" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/serializable-closure/zipball/b352cf0534aa1ae6b4d825d1e762e35d43f8a841", + "reference": "b352cf0534aa1ae6b4d825d1e762e35d43f8a841", + "shasum": "" + }, + "require": { + "php": "^8.1" + }, + "require-dev": { + "illuminate/support": "^10.0|^11.0|^12.0", + "nesbot/carbon": "^2.67|^3.0", + "pestphp/pest": "^2.36|^3.0", + "phpstan/phpstan": "^2.0", + "symfony/var-dumper": "^6.2.0|^7.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.x-dev" + } + }, + "autoload": { + "psr-4": { + "Laravel\\SerializableClosure\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + }, + { + "name": "Nuno Maduro", + "email": "nuno@laravel.com" + } + ], + "description": "Laravel Serializable Closure provides an easy and secure way to serialize closures in PHP.", + "keywords": [ + "closure", + "laravel", + "serializable" + ], + "support": { + "issues": "https://github.com/laravel/serializable-closure/issues", + "source": "https://github.com/laravel/serializable-closure" + }, + "time": "2025-03-19T13:51:03+00:00" + }, + { + "name": "laravel/socialite", + "version": "v5.21.0", + "source": { + "type": "git", + "url": "https://github.com/laravel/socialite.git", + "reference": "d83639499ad14985c9a6a9713b70073300ce998d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/socialite/zipball/d83639499ad14985c9a6a9713b70073300ce998d", + "reference": "d83639499ad14985c9a6a9713b70073300ce998d", + "shasum": "" + }, + "require": { + "ext-json": "*", + "firebase/php-jwt": "^6.4", + "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", + "illuminate/support": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0", + "league/oauth1-client": "^1.11", + "php": "^7.2|^8.0", + "phpseclib/phpseclib": "^3.0" + }, + "require-dev": { + "mockery/mockery": "^1.0", + "orchestra/testbench": "^4.0|^5.0|^6.0|^7.0|^8.0|^9.0|^10.0", + "phpstan/phpstan": "^1.12.23", + "phpunit/phpunit": "^8.0|^9.3|^10.4|^11.5" + }, + "type": "library", + "extra": { + "laravel": { + "aliases": { + "Socialite": "Laravel\\Socialite\\Facades\\Socialite" + }, + "providers": [ + "Laravel\\Socialite\\SocialiteServiceProvider" + ] + }, + "branch-alias": { + "dev-master": "5.x-dev" + } + }, + "autoload": { + "psr-4": { + "Laravel\\Socialite\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "Laravel wrapper around OAuth 1 & OAuth 2 libraries.", + "homepage": "https://laravel.com", + "keywords": [ + "laravel", + "oauth" + ], + "support": { + "issues": "https://github.com/laravel/socialite/issues", + "source": "https://github.com/laravel/socialite" + }, + "time": "2025-05-19T12:56:37+00:00" + }, + { + "name": "laravel/tinker", + "version": "v2.10.1", + "source": { + "type": "git", + "url": "https://github.com/laravel/tinker.git", + "reference": "22177cc71807d38f2810c6204d8f7183d88a57d3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/tinker/zipball/22177cc71807d38f2810c6204d8f7183d88a57d3", + "reference": "22177cc71807d38f2810c6204d8f7183d88a57d3", + "shasum": "" + }, + "require": { + "illuminate/console": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0", + "illuminate/contracts": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0", + "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" + }, + "require-dev": { + "mockery/mockery": "~1.3.3|^1.4.2", + "phpstan/phpstan": "^1.10", + "phpunit/phpunit": "^8.5.8|^9.3.3|^10.0" + }, + "suggest": { + "illuminate/database": "The Illuminate Database package (^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0)." + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Laravel\\Tinker\\TinkerServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Laravel\\Tinker\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "Powerful REPL for the Laravel framework.", + "keywords": [ + "REPL", + "Tinker", + "laravel", + "psysh" + ], + "support": { + "issues": "https://github.com/laravel/tinker/issues", + "source": "https://github.com/laravel/tinker/tree/v2.10.1" + }, + "time": "2025-01-27T14:24:01+00:00" + }, + { + "name": "lcobucci/clock", + "version": "3.3.1", + "source": { + "type": "git", + "url": "https://github.com/lcobucci/clock.git", + "reference": "db3713a61addfffd615b79bf0bc22f0ccc61b86b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/lcobucci/clock/zipball/db3713a61addfffd615b79bf0bc22f0ccc61b86b", + "reference": "db3713a61addfffd615b79bf0bc22f0ccc61b86b", + "shasum": "" + }, + "require": { + "php": "~8.2.0 || ~8.3.0 || ~8.4.0", + "psr/clock": "^1.0" + }, + "provide": { + "psr/clock-implementation": "1.0" + }, + "require-dev": { + "infection/infection": "^0.29", + "lcobucci/coding-standard": "^11.1.0", + "phpstan/extension-installer": "^1.3.1", + "phpstan/phpstan": "^1.10.25", + "phpstan/phpstan-deprecation-rules": "^1.1.3", + "phpstan/phpstan-phpunit": "^1.3.13", + "phpstan/phpstan-strict-rules": "^1.5.1", + "phpunit/phpunit": "^11.3.6" + }, + "type": "library", + "autoload": { + "psr-4": { + "Lcobucci\\Clock\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Luís Cobucci", + "email": "lcobucci@gmail.com" + } + ], + "description": "Yet another clock abstraction", + "support": { + "issues": "https://github.com/lcobucci/clock/issues", + "source": "https://github.com/lcobucci/clock/tree/3.3.1" + }, + "funding": [ + { + "url": "https://github.com/lcobucci", + "type": "github" + }, + { + "url": "https://www.patreon.com/lcobucci", + "type": "patreon" + } + ], + "time": "2024-09-24T20:45:14+00:00" + }, + { + "name": "lcobucci/jwt", + "version": "5.5.0", + "source": { + "type": "git", + "url": "https://github.com/lcobucci/jwt.git", + "reference": "a835af59b030d3f2967725697cf88300f579088e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/lcobucci/jwt/zipball/a835af59b030d3f2967725697cf88300f579088e", + "reference": "a835af59b030d3f2967725697cf88300f579088e", + "shasum": "" + }, + "require": { + "ext-openssl": "*", + "ext-sodium": "*", + "php": "~8.2.0 || ~8.3.0 || ~8.4.0", + "psr/clock": "^1.0" + }, + "require-dev": { + "infection/infection": "^0.29", + "lcobucci/clock": "^3.2", + "lcobucci/coding-standard": "^11.0", + "phpbench/phpbench": "^1.2", + "phpstan/extension-installer": "^1.2", + "phpstan/phpstan": "^1.10.7", + "phpstan/phpstan-deprecation-rules": "^1.1.3", + "phpstan/phpstan-phpunit": "^1.3.10", + "phpstan/phpstan-strict-rules": "^1.5.0", + "phpunit/phpunit": "^11.1" + }, + "suggest": { + "lcobucci/clock": ">= 3.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "Lcobucci\\JWT\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Luís Cobucci", + "email": "lcobucci@gmail.com", + "role": "Developer" + } + ], + "description": "A simple library to work with JSON Web Token and JSON Web Signature", + "keywords": [ + "JWS", + "jwt" + ], + "support": { + "issues": "https://github.com/lcobucci/jwt/issues", + "source": "https://github.com/lcobucci/jwt/tree/5.5.0" + }, + "funding": [ + { + "url": "https://github.com/lcobucci", + "type": "github" + }, + { + "url": "https://www.patreon.com/lcobucci", + "type": "patreon" + } + ], + "time": "2025-01-26T21:29:45+00:00" + }, + { + "name": "league/commonmark", + "version": "2.7.0", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/commonmark.git", + "reference": "6fbb36d44824ed4091adbcf4c7d4a3923cdb3405" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/commonmark/zipball/6fbb36d44824ed4091adbcf4c7d4a3923cdb3405", + "reference": "6fbb36d44824ed4091adbcf4c7d4a3923cdb3405", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "league/config": "^1.1.1", + "php": "^7.4 || ^8.0", + "psr/event-dispatcher": "^1.0", + "symfony/deprecation-contracts": "^2.1 || ^3.0", + "symfony/polyfill-php80": "^1.16" + }, + "require-dev": { + "cebe/markdown": "^1.0", + "commonmark/cmark": "0.31.1", + "commonmark/commonmark.js": "0.31.1", + "composer/package-versions-deprecated": "^1.8", + "embed/embed": "^4.4", + "erusev/parsedown": "^1.0", + "ext-json": "*", + "github/gfm": "0.29.0", + "michelf/php-markdown": "^1.4 || ^2.0", + "nyholm/psr7": "^1.5", + "phpstan/phpstan": "^1.8.2", + "phpunit/phpunit": "^9.5.21 || ^10.5.9 || ^11.0.0", + "scrutinizer/ocular": "^1.8.1", + "symfony/finder": "^5.3 | ^6.0 | ^7.0", + "symfony/process": "^5.4 | ^6.0 | ^7.0", + "symfony/yaml": "^2.3 | ^3.0 | ^4.0 | ^5.0 | ^6.0 | ^7.0", + "unleashedtech/php-coding-standard": "^3.1.1", + "vimeo/psalm": "^4.24.0 || ^5.0.0" + }, + "suggest": { + "symfony/yaml": "v2.3+ required if using the Front Matter extension" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "2.8-dev" + } + }, + "autoload": { + "psr-4": { + "League\\CommonMark\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Colin O'Dell", + "email": "colinodell@gmail.com", + "homepage": "https://www.colinodell.com", + "role": "Lead Developer" + } + ], + "description": "Highly-extensible PHP Markdown parser which fully supports the CommonMark spec and GitHub-Flavored Markdown (GFM)", + "homepage": "https://commonmark.thephpleague.com", + "keywords": [ + "commonmark", + "flavored", + "gfm", + "github", + "github-flavored", + "markdown", + "md", + "parser" + ], + "support": { + "docs": "https://commonmark.thephpleague.com/", + "forum": "https://github.com/thephpleague/commonmark/discussions", + "issues": "https://github.com/thephpleague/commonmark/issues", + "rss": "https://github.com/thephpleague/commonmark/releases.atom", + "source": "https://github.com/thephpleague/commonmark" + }, + "funding": [ + { + "url": "https://www.colinodell.com/sponsor", + "type": "custom" + }, + { + "url": "https://www.paypal.me/colinpodell/10.00", + "type": "custom" + }, + { + "url": "https://github.com/colinodell", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/league/commonmark", + "type": "tidelift" + } + ], + "time": "2025-05-05T12:20:28+00:00" + }, + { + "name": "league/config", + "version": "v1.2.0", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/config.git", + "reference": "754b3604fb2984c71f4af4a9cbe7b57f346ec1f3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/config/zipball/754b3604fb2984c71f4af4a9cbe7b57f346ec1f3", + "reference": "754b3604fb2984c71f4af4a9cbe7b57f346ec1f3", + "shasum": "" + }, + "require": { + "dflydev/dot-access-data": "^3.0.1", + "nette/schema": "^1.2", + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "phpstan/phpstan": "^1.8.2", + "phpunit/phpunit": "^9.5.5", + "scrutinizer/ocular": "^1.8.1", + "unleashedtech/php-coding-standard": "^3.1", + "vimeo/psalm": "^4.7.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.2-dev" + } + }, + "autoload": { + "psr-4": { + "League\\Config\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Colin O'Dell", + "email": "colinodell@gmail.com", + "homepage": "https://www.colinodell.com", + "role": "Lead Developer" + } + ], + "description": "Define configuration arrays with strict schemas and access values with dot notation", + "homepage": "https://config.thephpleague.com", + "keywords": [ + "array", + "config", + "configuration", + "dot", + "dot-access", + "nested", + "schema" + ], + "support": { + "docs": "https://config.thephpleague.com/", + "issues": "https://github.com/thephpleague/config/issues", + "rss": "https://github.com/thephpleague/config/releases.atom", + "source": "https://github.com/thephpleague/config" + }, + "funding": [ + { + "url": "https://www.colinodell.com/sponsor", + "type": "custom" + }, + { + "url": "https://www.paypal.me/colinpodell/10.00", + "type": "custom" + }, + { + "url": "https://github.com/colinodell", + "type": "github" + } + ], + "time": "2022-12-11T20:36:23+00:00" + }, + { + "name": "league/csv", + "version": "9.24.1", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/csv.git", + "reference": "e0221a3f16aa2a823047d59fab5809d552e29bc8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/csv/zipball/e0221a3f16aa2a823047d59fab5809d552e29bc8", + "reference": "e0221a3f16aa2a823047d59fab5809d552e29bc8", + "shasum": "" + }, + "require": { + "ext-filter": "*", + "php": "^8.1.2" + }, + "require-dev": { + "ext-dom": "*", + "ext-xdebug": "*", + "friendsofphp/php-cs-fixer": "^3.75.0", + "phpbench/phpbench": "^1.4.1", + "phpstan/phpstan": "^1.12.27", + "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", + "symfony/var-dumper": "^6.4.8 || ^7.3.0" + }, + "suggest": { + "ext-dom": "Required to use the XMLConverter and the HTMLConverter classes", + "ext-iconv": "Needed to ease transcoding CSV using iconv stream filters", + "ext-mbstring": "Needed to ease transcoding CSV using mb stream filters", + "ext-mysqli": "Requiered to use the package with the MySQLi extension", + "ext-pdo": "Required to use the package with the PDO extension", + "ext-pgsql": "Requiered to use the package with the PgSQL extension", + "ext-sqlite3": "Required to use the package with the SQLite3 extension" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "9.x-dev" + } + }, + "autoload": { + "files": [ + "src/functions_include.php" + ], + "psr-4": { + "League\\Csv\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ignace Nyamagana Butera", + "email": "nyamsprod@gmail.com", + "homepage": "https://github.com/nyamsprod/", + "role": "Developer" + } + ], + "description": "CSV data manipulation made easy in PHP", + "homepage": "https://csv.thephpleague.com", + "keywords": [ + "convert", + "csv", + "export", + "filter", + "import", + "read", + "transform", + "write" + ], + "support": { + "docs": "https://csv.thephpleague.com", + "issues": "https://github.com/thephpleague/csv/issues", + "rss": "https://github.com/thephpleague/csv/releases.atom", + "source": "https://github.com/thephpleague/csv" + }, + "funding": [ + { + "url": "https://github.com/sponsors/nyamsprod", + "type": "github" + } + ], + "time": "2025-06-25T14:53:51+00:00" + }, + { + "name": "league/event", + "version": "2.3.0", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/event.git", + "reference": "062ebb450efbe9a09bc2478e89b7c933875b0935" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/event/zipball/062ebb450efbe9a09bc2478e89b7c933875b0935", + "reference": "062ebb450efbe9a09bc2478e89b7c933875b0935", + "shasum": "" + }, + "require": { + "php": ">=7.1.0" + }, + "require-dev": { + "henrikbjorn/phpspec-code-coverage": "~1.0.1", + "phpspec/phpspec": "^2.2" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.2-dev" + } + }, + "autoload": { + "psr-4": { + "League\\Event\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Frank de Jonge", + "email": "info@frenky.net" + } + ], + "description": "Event package", + "keywords": [ + "emitter", + "event", + "listener" + ], + "support": { + "issues": "https://github.com/thephpleague/event/issues", + "source": "https://github.com/thephpleague/event/tree/2.3.0" + }, + "time": "2025-03-14T19:51:10+00:00" + }, + { + "name": "league/flysystem", + "version": "3.30.0", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/flysystem.git", + "reference": "2203e3151755d874bb2943649dae1eb8533ac93e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/flysystem/zipball/2203e3151755d874bb2943649dae1eb8533ac93e", + "reference": "2203e3151755d874bb2943649dae1eb8533ac93e", + "shasum": "" + }, + "require": { + "league/flysystem-local": "^3.0.0", + "league/mime-type-detection": "^1.0.0", + "php": "^8.0.2" + }, + "conflict": { + "async-aws/core": "<1.19.0", + "async-aws/s3": "<1.14.0", + "aws/aws-sdk-php": "3.209.31 || 3.210.0", + "guzzlehttp/guzzle": "<7.0", + "guzzlehttp/ringphp": "<1.1.1", + "phpseclib/phpseclib": "3.0.15", + "symfony/http-client": "<5.2" + }, + "require-dev": { + "async-aws/s3": "^1.5 || ^2.0", + "async-aws/simple-s3": "^1.1 || ^2.0", + "aws/aws-sdk-php": "^3.295.10", + "composer/semver": "^3.0", + "ext-fileinfo": "*", + "ext-ftp": "*", + "ext-mongodb": "^1.3|^2", + "ext-zip": "*", + "friendsofphp/php-cs-fixer": "^3.5", + "google/cloud-storage": "^1.23", + "guzzlehttp/psr7": "^2.6", + "microsoft/azure-storage-blob": "^1.1", + "mongodb/mongodb": "^1.2|^2", + "phpseclib/phpseclib": "^3.0.36", + "phpstan/phpstan": "^1.10", + "phpunit/phpunit": "^9.5.11|^10.0", + "sabre/dav": "^4.6.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "League\\Flysystem\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Frank de Jonge", + "email": "info@frankdejonge.nl" + } + ], + "description": "File storage abstraction for PHP", + "keywords": [ + "WebDAV", + "aws", + "cloud", + "file", + "files", + "filesystem", + "filesystems", + "ftp", + "s3", + "sftp", + "storage" + ], + "support": { + "issues": "https://github.com/thephpleague/flysystem/issues", + "source": "https://github.com/thephpleague/flysystem/tree/3.30.0" + }, + "time": "2025-06-25T13:29:59+00:00" + }, + { + "name": "league/flysystem-local", + "version": "3.30.0", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/flysystem-local.git", + "reference": "6691915f77c7fb69adfb87dcd550052dc184ee10" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/flysystem-local/zipball/6691915f77c7fb69adfb87dcd550052dc184ee10", + "reference": "6691915f77c7fb69adfb87dcd550052dc184ee10", + "shasum": "" + }, + "require": { + "ext-fileinfo": "*", + "league/flysystem": "^3.0.0", + "league/mime-type-detection": "^1.0.0", + "php": "^8.0.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "League\\Flysystem\\Local\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Frank de Jonge", + "email": "info@frankdejonge.nl" + } + ], + "description": "Local filesystem adapter for Flysystem.", + "keywords": [ + "Flysystem", + "file", + "files", + "filesystem", + "local" + ], + "support": { + "source": "https://github.com/thephpleague/flysystem-local/tree/3.30.0" + }, + "time": "2025-05-21T10:34:19+00:00" + }, + { + "name": "league/mime-type-detection", + "version": "1.16.0", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/mime-type-detection.git", + "reference": "2d6702ff215bf922936ccc1ad31007edc76451b9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/mime-type-detection/zipball/2d6702ff215bf922936ccc1ad31007edc76451b9", + "reference": "2d6702ff215bf922936ccc1ad31007edc76451b9", + "shasum": "" + }, + "require": { + "ext-fileinfo": "*", + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^3.2", + "phpstan/phpstan": "^0.12.68", + "phpunit/phpunit": "^8.5.8 || ^9.3 || ^10.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "League\\MimeTypeDetection\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Frank de Jonge", + "email": "info@frankdejonge.nl" + } + ], + "description": "Mime-type detection for Flysystem", + "support": { + "issues": "https://github.com/thephpleague/mime-type-detection/issues", + "source": "https://github.com/thephpleague/mime-type-detection/tree/1.16.0" + }, + "funding": [ + { + "url": "https://github.com/frankdejonge", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/league/flysystem", + "type": "tidelift" + } + ], + "time": "2024-09-21T08:32:55+00:00" + }, + { + "name": "league/oauth1-client", + "version": "v1.11.0", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/oauth1-client.git", + "reference": "f9c94b088837eb1aae1ad7c4f23eb65cc6993055" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/oauth1-client/zipball/f9c94b088837eb1aae1ad7c4f23eb65cc6993055", + "reference": "f9c94b088837eb1aae1ad7c4f23eb65cc6993055", + "shasum": "" + }, + "require": { + "ext-json": "*", + "ext-openssl": "*", + "guzzlehttp/guzzle": "^6.0|^7.0", + "guzzlehttp/psr7": "^1.7|^2.0", + "php": ">=7.1||>=8.0" + }, + "require-dev": { + "ext-simplexml": "*", + "friendsofphp/php-cs-fixer": "^2.17", + "mockery/mockery": "^1.3.3", + "phpstan/phpstan": "^0.12.42", + "phpunit/phpunit": "^7.5||9.5" + }, + "suggest": { + "ext-simplexml": "For decoding XML-based responses." + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0-dev", + "dev-develop": "2.0-dev" + } + }, + "autoload": { + "psr-4": { + "League\\OAuth1\\Client\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ben Corlett", + "email": "bencorlett@me.com", + "homepage": "http://www.webcomm.com.au", + "role": "Developer" + } + ], + "description": "OAuth 1.0 Client Library", + "keywords": [ + "Authentication", + "SSO", + "authorization", + "bitbucket", + "identity", + "idp", + "oauth", + "oauth1", + "single sign on", + "trello", + "tumblr", + "twitter" + ], + "support": { + "issues": "https://github.com/thephpleague/oauth1-client/issues", + "source": "https://github.com/thephpleague/oauth1-client/tree/v1.11.0" + }, + "time": "2024-12-10T19:59:05+00:00" + }, + { + "name": "league/oauth2-server", + "version": "8.5.5", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/oauth2-server.git", + "reference": "cc8778350f905667e796b3c2364a9d3bd7a73518" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/oauth2-server/zipball/cc8778350f905667e796b3c2364a9d3bd7a73518", + "reference": "cc8778350f905667e796b3c2364a9d3bd7a73518", + "shasum": "" + }, + "require": { + "defuse/php-encryption": "^2.3", + "ext-openssl": "*", + "lcobucci/clock": "^2.2 || ^3.0", + "lcobucci/jwt": "^4.3 || ^5.0", + "league/event": "^2.2", + "league/uri": "^6.7 || ^7.0", + "php": "^8.0", + "psr/http-message": "^1.0.1 || ^2.0" + }, + "replace": { + "league/oauth2server": "*", + "lncd/oauth2": "*" + }, + "require-dev": { + "laminas/laminas-diactoros": "^3.0.0", + "phpstan/phpstan": "^0.12.57", + "phpstan/phpstan-phpunit": "^0.12.16", + "phpunit/phpunit": "^9.6.6", + "roave/security-advisories": "dev-master" + }, + "type": "library", + "autoload": { + "psr-4": { + "League\\OAuth2\\Server\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Alex Bilbie", + "email": "hello@alexbilbie.com", + "homepage": "http://www.alexbilbie.com", + "role": "Developer" + }, + { + "name": "Andy Millington", + "email": "andrew@noexceptions.io", + "homepage": "https://www.noexceptions.io", + "role": "Developer" + } + ], + "description": "A lightweight and powerful OAuth 2.0 authorization and resource server library with support for all the core specification grants. This library will allow you to secure your API with OAuth and allow your applications users to approve apps that want to access their data from your API.", + "homepage": "https://oauth2.thephpleague.com/", + "keywords": [ + "Authentication", + "api", + "auth", + "authorisation", + "authorization", + "oauth", + "oauth 2", + "oauth 2.0", + "oauth2", + "protect", + "resource", + "secure", + "server" + ], + "support": { + "issues": "https://github.com/thephpleague/oauth2-server/issues", + "source": "https://github.com/thephpleague/oauth2-server/tree/8.5.5" + }, + "funding": [ + { + "url": "https://github.com/sephster", + "type": "github" + } + ], + "time": "2024-12-20T23:06:10+00:00" + }, + { + "name": "league/uri", + "version": "7.5.1", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/uri.git", + "reference": "81fb5145d2644324614cc532b28efd0215bda430" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/uri/zipball/81fb5145d2644324614cc532b28efd0215bda430", + "reference": "81fb5145d2644324614cc532b28efd0215bda430", + "shasum": "" + }, + "require": { + "league/uri-interfaces": "^7.5", + "php": "^8.1" + }, + "conflict": { + "league/uri-schemes": "^1.0" + }, + "suggest": { + "ext-bcmath": "to improve IPV4 host parsing", + "ext-fileinfo": "to create Data URI from file contennts", + "ext-gmp": "to improve IPV4 host parsing", + "ext-intl": "to handle IDN host with the best performance", + "jeremykendall/php-domain-parser": "to resolve Public Suffix and Top Level Domain", + "league/uri-components": "Needed to easily manipulate URI objects components", + "php-64bit": "to improve IPV4 host parsing", + "symfony/polyfill-intl-idn": "to handle IDN host via the Symfony polyfill if ext-intl is not present" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "7.x-dev" + } + }, + "autoload": { + "psr-4": { + "League\\Uri\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ignace Nyamagana Butera", + "email": "nyamsprod@gmail.com", + "homepage": "https://nyamsprod.com" + } + ], + "description": "URI manipulation library", + "homepage": "https://uri.thephpleague.com", + "keywords": [ + "data-uri", + "file-uri", + "ftp", + "hostname", + "http", + "https", + "middleware", + "parse_str", + "parse_url", + "psr-7", + "query-string", + "querystring", + "rfc3986", + "rfc3987", + "rfc6570", + "uri", + "uri-template", + "url", + "ws" + ], + "support": { + "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.5.1" + }, + "funding": [ + { + "url": "https://github.com/sponsors/nyamsprod", + "type": "github" + } + ], + "time": "2024-12-08T08:40:02+00:00" + }, + { + "name": "league/uri-interfaces", + "version": "7.5.0", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/uri-interfaces.git", + "reference": "08cfc6c4f3d811584fb09c37e2849e6a7f9b0742" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/uri-interfaces/zipball/08cfc6c4f3d811584fb09c37e2849e6a7f9b0742", + "reference": "08cfc6c4f3d811584fb09c37e2849e6a7f9b0742", + "shasum": "" + }, + "require": { + "ext-filter": "*", + "php": "^8.1", + "psr/http-factory": "^1", + "psr/http-message": "^1.1 || ^2.0" + }, + "suggest": { + "ext-bcmath": "to improve IPV4 host parsing", + "ext-gmp": "to improve IPV4 host parsing", + "ext-intl": "to handle IDN host with the best performance", + "php-64bit": "to improve IPV4 host parsing", + "symfony/polyfill-intl-idn": "to handle IDN host via the Symfony polyfill if ext-intl is not present" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "7.x-dev" + } + }, + "autoload": { + "psr-4": { + "League\\Uri\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ignace Nyamagana Butera", + "email": "nyamsprod@gmail.com", + "homepage": "https://nyamsprod.com" + } + ], + "description": "Common interfaces and classes for URI representation and interaction", + "homepage": "https://uri.thephpleague.com", + "keywords": [ + "data-uri", + "file-uri", + "ftp", + "hostname", + "http", + "https", + "parse_str", + "parse_url", + "psr-7", + "query-string", + "querystring", + "rfc3986", + "rfc3987", + "rfc6570", + "uri", + "url", + "ws" + ], + "support": { + "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.5.0" + }, + "funding": [ + { + "url": "https://github.com/sponsors/nyamsprod", + "type": "github" + } + ], + "time": "2024-12-08T08:18:47+00:00" + }, + { + "name": "livewire/livewire", + "version": "v3.6.4", + "source": { + "type": "git", + "url": "https://github.com/livewire/livewire.git", + "reference": "ef04be759da41b14d2d129e670533180a44987dc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/livewire/livewire/zipball/ef04be759da41b14d2d129e670533180a44987dc", + "reference": "ef04be759da41b14d2d129e670533180a44987dc", + "shasum": "" + }, + "require": { + "illuminate/database": "^10.0|^11.0|^12.0", + "illuminate/routing": "^10.0|^11.0|^12.0", + "illuminate/support": "^10.0|^11.0|^12.0", + "illuminate/validation": "^10.0|^11.0|^12.0", + "laravel/prompts": "^0.1.24|^0.2|^0.3", + "league/mime-type-detection": "^1.9", + "php": "^8.1", + "symfony/console": "^6.0|^7.0", + "symfony/http-kernel": "^6.2|^7.0" + }, + "require-dev": { + "calebporzio/sushi": "^2.1", + "laravel/framework": "^10.15.0|^11.0|^12.0", + "mockery/mockery": "^1.3.1", + "orchestra/testbench": "^8.21.0|^9.0|^10.0", + "orchestra/testbench-dusk": "^8.24|^9.1|^10.0", + "phpunit/phpunit": "^10.4|^11.5", + "psy/psysh": "^0.11.22|^0.12" + }, + "type": "library", + "extra": { + "laravel": { + "aliases": { + "Livewire": "Livewire\\Livewire" + }, + "providers": [ + "Livewire\\LivewireServiceProvider" + ] + } + }, + "autoload": { + "files": [ + "src/helpers.php" + ], + "psr-4": { + "Livewire\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Caleb Porzio", + "email": "calebporzio@gmail.com" + } + ], + "description": "A front-end framework for Laravel.", + "support": { + "issues": "https://github.com/livewire/livewire/issues", + "source": "https://github.com/livewire/livewire/tree/v3.6.4" + }, + "funding": [ + { + "url": "https://github.com/livewire", + "type": "github" + } + ], + "time": "2025-07-17T05:12:15+00:00" + }, + { + "name": "masterminds/html5", + "version": "2.9.0", + "source": { + "type": "git", + "url": "https://github.com/Masterminds/html5-php.git", + "reference": "f5ac2c0b0a2eefca70b2ce32a5809992227e75a6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Masterminds/html5-php/zipball/f5ac2c0b0a2eefca70b2ce32a5809992227e75a6", + "reference": "f5ac2c0b0a2eefca70b2ce32a5809992227e75a6", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "php": ">=5.3.0" + }, + "require-dev": { + "phpunit/phpunit": "^4.8.35 || ^5.7.21 || ^6 || ^7 || ^8 || ^9" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.7-dev" + } + }, + "autoload": { + "psr-4": { + "Masterminds\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Matt Butcher", + "email": "technosophos@gmail.com" + }, + { + "name": "Matt Farina", + "email": "matt@mattfarina.com" + }, + { + "name": "Asmir Mustafic", + "email": "goetas@gmail.com" + } + ], + "description": "An HTML5 parser and serializer.", + "homepage": "http://masterminds.github.io/html5-php", + "keywords": [ + "HTML5", + "dom", + "html", + "parser", + "querypath", + "serializer", + "xml" + ], + "support": { + "issues": "https://github.com/Masterminds/html5-php/issues", + "source": "https://github.com/Masterminds/html5-php/tree/2.9.0" + }, + "time": "2024-03-31T07:05:07+00:00" + }, + { + "name": "monolog/monolog", + "version": "3.9.0", + "source": { + "type": "git", + "url": "https://github.com/Seldaek/monolog.git", + "reference": "10d85740180ecba7896c87e06a166e0c95a0e3b6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Seldaek/monolog/zipball/10d85740180ecba7896c87e06a166e0c95a0e3b6", + "reference": "10d85740180ecba7896c87e06a166e0c95a0e3b6", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "psr/log": "^2.0 || ^3.0" + }, + "provide": { + "psr/log-implementation": "3.0.0" + }, + "require-dev": { + "aws/aws-sdk-php": "^3.0", + "doctrine/couchdb": "~1.0@dev", + "elasticsearch/elasticsearch": "^7 || ^8", + "ext-json": "*", + "graylog2/gelf-php": "^1.4.2 || ^2.0", + "guzzlehttp/guzzle": "^7.4.5", + "guzzlehttp/psr7": "^2.2", + "mongodb/mongodb": "^1.8", + "php-amqplib/php-amqplib": "~2.4 || ^3", + "php-console/php-console": "^3.1.8", + "phpstan/phpstan": "^2", + "phpstan/phpstan-deprecation-rules": "^2", + "phpstan/phpstan-strict-rules": "^2", + "phpunit/phpunit": "^10.5.17 || ^11.0.7", + "predis/predis": "^1.1 || ^2", + "rollbar/rollbar": "^4.0", + "ruflin/elastica": "^7 || ^8", + "symfony/mailer": "^5.4 || ^6", + "symfony/mime": "^5.4 || ^6" + }, + "suggest": { + "aws/aws-sdk-php": "Allow sending log messages to AWS services like DynamoDB", + "doctrine/couchdb": "Allow sending log messages to a CouchDB server", + "elasticsearch/elasticsearch": "Allow sending log messages to an Elasticsearch server via official client", + "ext-amqp": "Allow sending log messages to an AMQP server (1.0+ required)", + "ext-curl": "Required to send log messages using the IFTTTHandler, the LogglyHandler, the SendGridHandler, the SlackWebhookHandler or the TelegramBotHandler", + "ext-mbstring": "Allow to work properly with unicode symbols", + "ext-mongodb": "Allow sending log messages to a MongoDB server (via driver)", + "ext-openssl": "Required to send log messages using SSL", + "ext-sockets": "Allow sending log messages to a Syslog server (via UDP driver)", + "graylog2/gelf-php": "Allow sending log messages to a GrayLog2 server", + "mongodb/mongodb": "Allow sending log messages to a MongoDB server (via library)", + "php-amqplib/php-amqplib": "Allow sending log messages to an AMQP server using php-amqplib", + "rollbar/rollbar": "Allow sending log messages to Rollbar", + "ruflin/elastica": "Allow sending log messages to an Elastic Search server" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Monolog\\": "src/Monolog" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "https://seld.be" + } + ], + "description": "Sends your logs to files, sockets, inboxes, databases and various web services", + "homepage": "https://github.com/Seldaek/monolog", + "keywords": [ + "log", + "logging", + "psr-3" + ], + "support": { + "issues": "https://github.com/Seldaek/monolog/issues", + "source": "https://github.com/Seldaek/monolog/tree/3.9.0" + }, + "funding": [ + { + "url": "https://github.com/Seldaek", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/monolog/monolog", + "type": "tidelift" + } + ], + "time": "2025-03-24T10:02:05+00:00" + }, + { + "name": "nesbot/carbon", + "version": "3.10.1", + "source": { + "type": "git", + "url": "https://github.com/CarbonPHP/carbon.git", + "reference": "1fd1935b2d90aef2f093c5e35f7ae1257c448d00" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/CarbonPHP/carbon/zipball/1fd1935b2d90aef2f093c5e35f7ae1257c448d00", + "reference": "1fd1935b2d90aef2f093c5e35f7ae1257c448d00", + "shasum": "" + }, + "require": { + "carbonphp/carbon-doctrine-types": "<100.0", + "ext-json": "*", + "php": "^8.1", + "psr/clock": "^1.0", + "symfony/clock": "^6.3.12 || ^7.0", + "symfony/polyfill-mbstring": "^1.0", + "symfony/translation": "^4.4.18 || ^5.2.1 || ^6.0 || ^7.0" + }, + "provide": { + "psr/clock-implementation": "1.0" + }, + "require-dev": { + "doctrine/dbal": "^3.6.3 || ^4.0", + "doctrine/orm": "^2.15.2 || ^3.0", + "friendsofphp/php-cs-fixer": "^3.75.0", + "kylekatarnls/multi-tester": "^2.5.3", + "phpmd/phpmd": "^2.15.0", + "phpstan/extension-installer": "^1.4.3", + "phpstan/phpstan": "^2.1.17", + "phpunit/phpunit": "^10.5.46", + "squizlabs/php_codesniffer": "^3.13.0" + }, + "bin": [ + "bin/carbon" + ], + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Carbon\\Laravel\\ServiceProvider" + ] + }, + "phpstan": { + "includes": [ + "extension.neon" + ] + }, + "branch-alias": { + "dev-2.x": "2.x-dev", + "dev-master": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Carbon\\": "src/Carbon/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Brian Nesbitt", + "email": "brian@nesbot.com", + "homepage": "https://markido.com" + }, + { + "name": "kylekatarnls", + "homepage": "https://github.com/kylekatarnls" + } + ], + "description": "An API extension for DateTime that supports 281 different languages.", + "homepage": "https://carbon.nesbot.com", + "keywords": [ + "date", + "datetime", + "time" + ], + "support": { + "docs": "https://carbon.nesbot.com/docs", + "issues": "https://github.com/CarbonPHP/carbon/issues", + "source": "https://github.com/CarbonPHP/carbon" + }, + "funding": [ + { + "url": "https://github.com/sponsors/kylekatarnls", + "type": "github" + }, + { + "url": "https://opencollective.com/Carbon#sponsor", + "type": "opencollective" + }, + { + "url": "https://tidelift.com/subscription/pkg/packagist-nesbot-carbon?utm_source=packagist-nesbot-carbon&utm_medium=referral&utm_campaign=readme", + "type": "tidelift" + } + ], + "time": "2025-06-21T15:19:35+00:00" + }, + { + "name": "nette/schema", + "version": "v1.3.2", + "source": { + "type": "git", + "url": "https://github.com/nette/schema.git", + "reference": "da801d52f0354f70a638673c4a0f04e16529431d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nette/schema/zipball/da801d52f0354f70a638673c4a0f04e16529431d", + "reference": "da801d52f0354f70a638673c4a0f04e16529431d", + "shasum": "" + }, + "require": { + "nette/utils": "^4.0", + "php": "8.1 - 8.4" + }, + "require-dev": { + "nette/tester": "^2.5.2", + "phpstan/phpstan-nette": "^1.0", + "tracy/tracy": "^2.8" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.3-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause", + "GPL-2.0-only", + "GPL-3.0-only" + ], + "authors": [ + { + "name": "David Grudl", + "homepage": "https://davidgrudl.com" + }, + { + "name": "Nette Community", + "homepage": "https://nette.org/contributors" + } + ], + "description": "📐 Nette Schema: validating data structures against a given Schema.", + "homepage": "https://nette.org", + "keywords": [ + "config", + "nette" + ], + "support": { + "issues": "https://github.com/nette/schema/issues", + "source": "https://github.com/nette/schema/tree/v1.3.2" + }, + "time": "2024-10-06T23:10:23+00:00" + }, + { + "name": "nette/utils", + "version": "v4.0.7", + "source": { + "type": "git", + "url": "https://github.com/nette/utils.git", + "reference": "e67c4061eb40b9c113b218214e42cb5a0dda28f2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nette/utils/zipball/e67c4061eb40b9c113b218214e42cb5a0dda28f2", + "reference": "e67c4061eb40b9c113b218214e42cb5a0dda28f2", + "shasum": "" + }, + "require": { + "php": "8.0 - 8.4" + }, + "conflict": { + "nette/finder": "<3", + "nette/schema": "<1.2.2" + }, + "require-dev": { + "jetbrains/phpstorm-attributes": "dev-master", + "nette/tester": "^2.5", + "phpstan/phpstan": "^1.0", + "tracy/tracy": "^2.9" + }, + "suggest": { + "ext-gd": "to use Image", + "ext-iconv": "to use Strings::webalize(), toAscii(), chr() and reverse()", + "ext-intl": "to use Strings::webalize(), toAscii(), normalize() and compare()", + "ext-json": "to use Nette\\Utils\\Json", + "ext-mbstring": "to use Strings::lower() etc...", + "ext-tokenizer": "to use Nette\\Utils\\Reflection::getUseStatements()" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause", + "GPL-2.0-only", + "GPL-3.0-only" + ], + "authors": [ + { + "name": "David Grudl", + "homepage": "https://davidgrudl.com" + }, + { + "name": "Nette Community", + "homepage": "https://nette.org/contributors" + } + ], + "description": "🛠 Nette Utils: lightweight utilities for string & array manipulation, image handling, safe JSON encoding/decoding, validation, slug or strong password generating etc.", + "homepage": "https://nette.org", + "keywords": [ + "array", + "core", + "datetime", + "images", + "json", + "nette", + "paginator", + "password", + "slugify", + "string", + "unicode", + "utf-8", + "utility", + "validation" + ], + "support": { + "issues": "https://github.com/nette/utils/issues", + "source": "https://github.com/nette/utils/tree/v4.0.7" + }, + "time": "2025-06-03T04:55:08+00:00" + }, + { + "name": "nikic/php-parser", + "version": "v5.5.0", + "source": { + "type": "git", + "url": "https://github.com/nikic/PHP-Parser.git", + "reference": "ae59794362fe85e051a58ad36b289443f57be7a9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/ae59794362fe85e051a58ad36b289443f57be7a9", + "reference": "ae59794362fe85e051a58ad36b289443f57be7a9", + "shasum": "" + }, + "require": { + "ext-ctype": "*", + "ext-json": "*", + "ext-tokenizer": "*", + "php": ">=7.4" + }, + "require-dev": { + "ircmaxell/php-yacc": "^0.0.7", + "phpunit/phpunit": "^9.0" + }, + "bin": [ + "bin/php-parse" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.0-dev" + } + }, + "autoload": { + "psr-4": { + "PhpParser\\": "lib/PhpParser" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Nikita Popov" + } + ], + "description": "A PHP parser written in PHP", + "keywords": [ + "parser", + "php" + ], + "support": { + "issues": "https://github.com/nikic/PHP-Parser/issues", + "source": "https://github.com/nikic/PHP-Parser/tree/v5.5.0" + }, + "time": "2025-05-31T08:24:38+00:00" + }, + { + "name": "nunomaduro/termwind", + "version": "v2.3.1", + "source": { + "type": "git", + "url": "https://github.com/nunomaduro/termwind.git", + "reference": "dfa08f390e509967a15c22493dc0bac5733d9123" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nunomaduro/termwind/zipball/dfa08f390e509967a15c22493dc0bac5733d9123", + "reference": "dfa08f390e509967a15c22493dc0bac5733d9123", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "php": "^8.2", + "symfony/console": "^7.2.6" + }, + "require-dev": { + "illuminate/console": "^11.44.7", + "laravel/pint": "^1.22.0", + "mockery/mockery": "^1.6.12", + "pestphp/pest": "^2.36.0 || ^3.8.2", + "phpstan/phpstan": "^1.12.25", + "phpstan/phpstan-strict-rules": "^1.6.2", + "symfony/var-dumper": "^7.2.6", + "thecodingmachine/phpstan-strict-rules": "^1.0.0" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Termwind\\Laravel\\TermwindServiceProvider" + ] + }, + "branch-alias": { + "dev-2.x": "2.x-dev" + } + }, + "autoload": { + "files": [ + "src/Functions.php" + ], + "psr-4": { + "Termwind\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nuno Maduro", + "email": "enunomaduro@gmail.com" + } + ], + "description": "Its like Tailwind CSS, but for the console.", + "keywords": [ + "cli", + "console", + "css", + "package", + "php", + "style" + ], + "support": { + "issues": "https://github.com/nunomaduro/termwind/issues", + "source": "https://github.com/nunomaduro/termwind/tree/v2.3.1" + }, + "funding": [ + { + "url": "https://www.paypal.com/paypalme/enunomaduro", + "type": "custom" + }, + { + "url": "https://github.com/nunomaduro", + "type": "github" + }, + { + "url": "https://github.com/xiCO2k", + "type": "github" + } + ], + "time": "2025-05-08T08:14:37+00:00" + }, + { + "name": "nyholm/psr7", + "version": "1.8.2", + "source": { + "type": "git", + "url": "https://github.com/Nyholm/psr7.git", + "reference": "a71f2b11690f4b24d099d6b16690a90ae14fc6f3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Nyholm/psr7/zipball/a71f2b11690f4b24d099d6b16690a90ae14fc6f3", + "reference": "a71f2b11690f4b24d099d6b16690a90ae14fc6f3", + "shasum": "" + }, + "require": { + "php": ">=7.2", + "psr/http-factory": "^1.0", + "psr/http-message": "^1.1 || ^2.0" + }, + "provide": { + "php-http/message-factory-implementation": "1.0", + "psr/http-factory-implementation": "1.0", + "psr/http-message-implementation": "1.0" + }, + "require-dev": { + "http-interop/http-factory-tests": "^0.9", + "php-http/message-factory": "^1.0", + "php-http/psr7-integration-tests": "^1.0", + "phpunit/phpunit": "^7.5 || ^8.5 || ^9.4", + "symfony/error-handler": "^4.4" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.8-dev" + } + }, + "autoload": { + "psr-4": { + "Nyholm\\Psr7\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com" + }, + { + "name": "Martijn van der Ven", + "email": "martijn@vanderven.se" + } + ], + "description": "A fast PHP7 implementation of PSR-7", + "homepage": "https://tnyholm.se", + "keywords": [ + "psr-17", + "psr-7" + ], + "support": { + "issues": "https://github.com/Nyholm/psr7/issues", + "source": "https://github.com/Nyholm/psr7/tree/1.8.2" + }, + "funding": [ + { + "url": "https://github.com/Zegnat", + "type": "github" + }, + { + "url": "https://github.com/nyholm", + "type": "github" + } + ], + "time": "2024-09-09T07:06:30+00:00" + }, + { + "name": "openspout/openspout", + "version": "v4.30.1", + "source": { + "type": "git", + "url": "https://github.com/openspout/openspout.git", + "reference": "4550fc0dbf01aff86d12691f8a7f6ce22d2b2edc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/openspout/openspout/zipball/4550fc0dbf01aff86d12691f8a7f6ce22d2b2edc", + "reference": "4550fc0dbf01aff86d12691f8a7f6ce22d2b2edc", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-fileinfo": "*", + "ext-filter": "*", + "ext-libxml": "*", + "ext-xmlreader": "*", + "ext-zip": "*", + "php": "~8.3.0 || ~8.4.0" + }, + "require-dev": { + "ext-zlib": "*", + "friendsofphp/php-cs-fixer": "^3.80.0", + "infection/infection": "^0.30.1", + "phpbench/phpbench": "^1.4.1", + "phpstan/phpstan": "^2.1.17", + "phpstan/phpstan-phpunit": "^2.0.6", + "phpstan/phpstan-strict-rules": "^2.0.4", + "phpunit/phpunit": "^12.2.6" + }, + "suggest": { + "ext-iconv": "To handle non UTF-8 CSV files (if \"php-mbstring\" is not already installed or is too limited)", + "ext-mbstring": "To handle non UTF-8 CSV files (if \"iconv\" is not already installed)" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.3.x-dev" + } + }, + "autoload": { + "psr-4": { + "OpenSpout\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Adrien Loison", + "email": "adrien@box.com" + } + ], + "description": "PHP Library to read and write spreadsheet files (CSV, XLSX and ODS), in a fast and scalable way", + "homepage": "https://github.com/openspout/openspout", + "keywords": [ + "OOXML", + "csv", + "excel", + "memory", + "odf", + "ods", + "office", + "open", + "php", + "read", + "scale", + "spreadsheet", + "stream", + "write", + "xlsx" + ], + "support": { + "issues": "https://github.com/openspout/openspout/issues", + "source": "https://github.com/openspout/openspout/tree/v4.30.1" + }, + "funding": [ + { + "url": "https://paypal.me/filippotessarotto", + "type": "custom" + }, + { + "url": "https://github.com/Slamdunk", + "type": "github" + } + ], + "time": "2025-07-07T06:15:55+00:00" + }, + { + "name": "paragonie/constant_time_encoding", + "version": "v3.0.0", + "source": { + "type": "git", + "url": "https://github.com/paragonie/constant_time_encoding.git", + "reference": "df1e7fde177501eee2037dd159cf04f5f301a512" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/paragonie/constant_time_encoding/zipball/df1e7fde177501eee2037dd159cf04f5f301a512", + "reference": "df1e7fde177501eee2037dd159cf04f5f301a512", + "shasum": "" + }, + "require": { + "php": "^8" + }, + "require-dev": { + "phpunit/phpunit": "^9", + "vimeo/psalm": "^4|^5" + }, + "type": "library", + "autoload": { + "psr-4": { + "ParagonIE\\ConstantTime\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Paragon Initiative Enterprises", + "email": "security@paragonie.com", + "homepage": "https://paragonie.com", + "role": "Maintainer" + }, + { + "name": "Steve 'Sc00bz' Thomas", + "email": "steve@tobtu.com", + "homepage": "https://www.tobtu.com", + "role": "Original Developer" + } + ], + "description": "Constant-time Implementations of RFC 4648 Encoding (Base-64, Base-32, Base-16)", + "keywords": [ + "base16", + "base32", + "base32_decode", + "base32_encode", + "base64", + "base64_decode", + "base64_encode", + "bin2hex", + "encoding", + "hex", + "hex2bin", + "rfc4648" + ], + "support": { + "email": "info@paragonie.com", + "issues": "https://github.com/paragonie/constant_time_encoding/issues", + "source": "https://github.com/paragonie/constant_time_encoding" + }, + "time": "2024-05-08T12:36:18+00:00" + }, + { + "name": "paragonie/random_compat", + "version": "v9.99.100", + "source": { + "type": "git", + "url": "https://github.com/paragonie/random_compat.git", + "reference": "996434e5492cb4c3edcb9168db6fbb1359ef965a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/paragonie/random_compat/zipball/996434e5492cb4c3edcb9168db6fbb1359ef965a", + "reference": "996434e5492cb4c3edcb9168db6fbb1359ef965a", + "shasum": "" + }, + "require": { + "php": ">= 7" + }, + "require-dev": { + "phpunit/phpunit": "4.*|5.*", + "vimeo/psalm": "^1" + }, + "suggest": { + "ext-libsodium": "Provides a modern crypto API that can be used to generate random bytes." + }, + "type": "library", + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Paragon Initiative Enterprises", + "email": "security@paragonie.com", + "homepage": "https://paragonie.com" + } + ], + "description": "PHP 5.x polyfill for random_bytes() and random_int() from PHP 7", + "keywords": [ + "csprng", + "polyfill", + "pseudorandom", + "random" + ], + "support": { + "email": "info@paragonie.com", + "issues": "https://github.com/paragonie/random_compat/issues", + "source": "https://github.com/paragonie/random_compat" + }, + "time": "2020-10-15T08:29:30+00:00" + }, + { + "name": "phpoption/phpoption", + "version": "1.9.3", + "source": { + "type": "git", + "url": "https://github.com/schmittjoh/php-option.git", + "reference": "e3fac8b24f56113f7cb96af14958c0dd16330f54" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/schmittjoh/php-option/zipball/e3fac8b24f56113f7cb96af14958c0dd16330f54", + "reference": "e3fac8b24f56113f7cb96af14958c0dd16330f54", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "phpunit/phpunit": "^8.5.39 || ^9.6.20 || ^10.5.28" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + }, + "branch-alias": { + "dev-master": "1.9-dev" + } + }, + "autoload": { + "psr-4": { + "PhpOption\\": "src/PhpOption/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "authors": [ + { + "name": "Johannes M. Schmitt", + "email": "schmittjoh@gmail.com", + "homepage": "https://github.com/schmittjoh" + }, + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + } + ], + "description": "Option Type for PHP", + "keywords": [ + "language", + "option", + "php", + "type" + ], + "support": { + "issues": "https://github.com/schmittjoh/php-option/issues", + "source": "https://github.com/schmittjoh/php-option/tree/1.9.3" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpoption/phpoption", + "type": "tidelift" + } + ], + "time": "2024-07-20T21:41:07+00:00" + }, + { + "name": "phpseclib/phpseclib", + "version": "3.0.46", + "source": { + "type": "git", + "url": "https://github.com/phpseclib/phpseclib.git", + "reference": "56483a7de62a6c2a6635e42e93b8a9e25d4f0ec6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpseclib/phpseclib/zipball/56483a7de62a6c2a6635e42e93b8a9e25d4f0ec6", + "reference": "56483a7de62a6c2a6635e42e93b8a9e25d4f0ec6", + "shasum": "" + }, + "require": { + "paragonie/constant_time_encoding": "^1|^2|^3", + "paragonie/random_compat": "^1.4|^2.0|^9.99.99", + "php": ">=5.6.1" + }, + "require-dev": { + "phpunit/phpunit": "*" + }, + "suggest": { + "ext-dom": "Install the DOM extension to load XML formatted public keys.", + "ext-gmp": "Install the GMP (GNU Multiple Precision) extension in order to speed up arbitrary precision integer arithmetic operations.", + "ext-libsodium": "SSH2/SFTP can make use of some algorithms provided by the libsodium-php extension.", + "ext-mcrypt": "Install the Mcrypt extension in order to speed up a few other cryptographic operations.", + "ext-openssl": "Install the OpenSSL extension in order to speed up a wide variety of cryptographic operations." + }, + "type": "library", + "autoload": { + "files": [ + "phpseclib/bootstrap.php" + ], + "psr-4": { + "phpseclib3\\": "phpseclib/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jim Wigginton", + "email": "terrafrost@php.net", + "role": "Lead Developer" + }, + { + "name": "Patrick Monnerat", + "email": "pm@datasphere.ch", + "role": "Developer" + }, + { + "name": "Andreas Fischer", + "email": "bantu@phpbb.com", + "role": "Developer" + }, + { + "name": "Hans-Jürgen Petrich", + "email": "petrich@tronic-media.com", + "role": "Developer" + }, + { + "name": "Graham Campbell", + "email": "graham@alt-three.com", + "role": "Developer" + } + ], + "description": "PHP Secure Communications Library - Pure-PHP implementations of RSA, AES, SSH2, SFTP, X.509 etc.", + "homepage": "http://phpseclib.sourceforge.net", + "keywords": [ + "BigInteger", + "aes", + "asn.1", + "asn1", + "blowfish", + "crypto", + "cryptography", + "encryption", + "rsa", + "security", + "sftp", + "signature", + "signing", + "ssh", + "twofish", + "x.509", + "x509" + ], + "support": { + "issues": "https://github.com/phpseclib/phpseclib/issues", + "source": "https://github.com/phpseclib/phpseclib/tree/3.0.46" + }, + "funding": [ + { + "url": "https://github.com/terrafrost", + "type": "github" + }, + { + "url": "https://www.patreon.com/phpseclib", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpseclib/phpseclib", + "type": "tidelift" + } + ], + "time": "2025-06-26T16:29:55+00:00" + }, + { + "name": "psr/cache", + "version": "3.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/cache.git", + "reference": "aa5030cfa5405eccfdcb1083ce040c2cb8d253bf" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/cache/zipball/aa5030cfa5405eccfdcb1083ce040c2cb8d253bf", + "reference": "aa5030cfa5405eccfdcb1083ce040c2cb8d253bf", + "shasum": "" + }, + "require": { + "php": ">=8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Cache\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for caching libraries", + "keywords": [ + "cache", + "psr", + "psr-6" + ], + "support": { + "source": "https://github.com/php-fig/cache/tree/3.0.0" + }, + "time": "2021-02-03T23:26:27+00:00" + }, + { + "name": "psr/clock", + "version": "1.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/clock.git", + "reference": "e41a24703d4560fd0acb709162f73b8adfc3aa0d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/clock/zipball/e41a24703d4560fd0acb709162f73b8adfc3aa0d", + "reference": "e41a24703d4560fd0acb709162f73b8adfc3aa0d", + "shasum": "" + }, + "require": { + "php": "^7.0 || ^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Psr\\Clock\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for reading the clock.", + "homepage": "https://github.com/php-fig/clock", + "keywords": [ + "clock", + "now", + "psr", + "psr-20", + "time" + ], + "support": { + "issues": "https://github.com/php-fig/clock/issues", + "source": "https://github.com/php-fig/clock/tree/1.0.0" + }, + "time": "2022-11-25T14:36:26+00:00" + }, + { + "name": "psr/container", + "version": "2.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/container.git", + "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/container/zipball/c71ecc56dfe541dbd90c5360474fbc405f8d5963", + "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963", + "shasum": "" + }, + "require": { + "php": ">=7.4.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Container\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common Container Interface (PHP FIG PSR-11)", + "homepage": "https://github.com/php-fig/container", + "keywords": [ + "PSR-11", + "container", + "container-interface", + "container-interop", + "psr" + ], + "support": { + "issues": "https://github.com/php-fig/container/issues", + "source": "https://github.com/php-fig/container/tree/2.0.2" + }, + "time": "2021-11-05T16:47:00+00:00" + }, + { + "name": "psr/event-dispatcher", + "version": "1.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/event-dispatcher.git", + "reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/event-dispatcher/zipball/dbefd12671e8a14ec7f180cab83036ed26714bb0", + "reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0", + "shasum": "" + }, + "require": { + "php": ">=7.2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\EventDispatcher\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "http://www.php-fig.org/" + } + ], + "description": "Standard interfaces for event handling.", + "keywords": [ + "events", + "psr", + "psr-14" + ], + "support": { + "issues": "https://github.com/php-fig/event-dispatcher/issues", + "source": "https://github.com/php-fig/event-dispatcher/tree/1.0.0" + }, + "time": "2019-01-08T18:20:26+00:00" + }, + { + "name": "psr/http-client", + "version": "1.0.3", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-client.git", + "reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-client/zipball/bb5906edc1c324c9a05aa0873d40117941e5fa90", + "reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90", + "shasum": "" + }, + "require": { + "php": "^7.0 || ^8.0", + "psr/http-message": "^1.0 || ^2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Client\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP clients", + "homepage": "https://github.com/php-fig/http-client", + "keywords": [ + "http", + "http-client", + "psr", + "psr-18" + ], + "support": { + "source": "https://github.com/php-fig/http-client" + }, + "time": "2023-09-23T14:17:50+00:00" + }, + { + "name": "psr/http-factory", + "version": "1.1.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-factory.git", + "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-factory/zipball/2b4765fddfe3b508ac62f829e852b1501d3f6e8a", + "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a", + "shasum": "" + }, + "require": { + "php": ">=7.1", + "psr/http-message": "^1.0 || ^2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Message\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "PSR-17: Common interfaces for PSR-7 HTTP message factories", + "keywords": [ + "factory", + "http", + "message", + "psr", + "psr-17", + "psr-7", + "request", + "response" + ], + "support": { + "source": "https://github.com/php-fig/http-factory" + }, + "time": "2024-04-15T12:06:14+00:00" + }, + { + "name": "psr/http-message", + "version": "2.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-message.git", + "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-message/zipball/402d35bcb92c70c026d1a6a9883f06b2ead23d71", + "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Message\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP messages", + "homepage": "https://github.com/php-fig/http-message", + "keywords": [ + "http", + "http-message", + "psr", + "psr-7", + "request", + "response" + ], + "support": { + "source": "https://github.com/php-fig/http-message/tree/2.0" + }, + "time": "2023-04-04T09:54:51+00:00" + }, + { + "name": "psr/log", + "version": "3.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/log.git", + "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/log/zipball/f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", + "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", + "shasum": "" + }, + "require": { + "php": ">=8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Log\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for logging libraries", + "homepage": "https://github.com/php-fig/log", + "keywords": [ + "log", + "psr", + "psr-3" + ], + "support": { + "source": "https://github.com/php-fig/log/tree/3.0.2" + }, + "time": "2024-09-11T13:17:53+00:00" + }, + { + "name": "psr/simple-cache", + "version": "3.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/simple-cache.git", + "reference": "764e0b3939f5ca87cb904f570ef9be2d78a07865" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/simple-cache/zipball/764e0b3939f5ca87cb904f570ef9be2d78a07865", + "reference": "764e0b3939f5ca87cb904f570ef9be2d78a07865", + "shasum": "" + }, + "require": { + "php": ">=8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\SimpleCache\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interfaces for simple caching", + "keywords": [ + "cache", + "caching", + "psr", + "psr-16", + "simple-cache" + ], + "support": { + "source": "https://github.com/php-fig/simple-cache/tree/3.0.0" + }, + "time": "2021-10-29T13:26:27+00:00" + }, + { + "name": "psy/psysh", + "version": "v0.12.9", + "source": { + "type": "git", + "url": "https://github.com/bobthecow/psysh.git", + "reference": "1b801844becfe648985372cb4b12ad6840245ace" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/bobthecow/psysh/zipball/1b801844becfe648985372cb4b12ad6840245ace", + "reference": "1b801844becfe648985372cb4b12ad6840245ace", + "shasum": "" + }, + "require": { + "ext-json": "*", + "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" + }, + "conflict": { + "symfony/console": "4.4.37 || 5.3.14 || 5.3.15 || 5.4.3 || 5.4.4 || 6.0.3 || 6.0.4" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.2" + }, + "suggest": { + "ext-pcntl": "Enabling the PCNTL extension makes PsySH a lot happier :)", + "ext-pdo-sqlite": "The doc command requires SQLite to work.", + "ext-posix": "If you have PCNTL, you'll want the POSIX extension as well." + }, + "bin": [ + "bin/psysh" + ], + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": false, + "forward-command": false + }, + "branch-alias": { + "dev-main": "0.12.x-dev" + } + }, + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "Psy\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Justin Hileman", + "email": "justin@justinhileman.info", + "homepage": "http://justinhileman.com" + } + ], + "description": "An interactive shell for modern PHP.", + "homepage": "http://psysh.org", + "keywords": [ + "REPL", + "console", + "interactive", + "shell" + ], + "support": { + "issues": "https://github.com/bobthecow/psysh/issues", + "source": "https://github.com/bobthecow/psysh/tree/v0.12.9" + }, + "time": "2025-06-23T02:35:06+00:00" + }, + { + "name": "qirolab/laravel-themer", + "version": "2.4.0", + "source": { + "type": "git", + "url": "https://github.com/qirolab/laravel-themer.git", + "reference": "7a4345c2508a616ad309812840758f3fc01ababd" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/qirolab/laravel-themer/zipball/7a4345c2508a616ad309812840758f3fc01ababd", + "reference": "7a4345c2508a616ad309812840758f3fc01ababd", + "shasum": "" + }, + "require": { + "facade/ignition-contracts": "^1.0", + "illuminate/support": "^9.19|^10.0|^11.0|^12.0", + "php": ">=7.1.0" + }, + "require-dev": { + "orchestra/testbench": "^7.0|^8.0|^9.0|^10.0", + "phpunit/phpunit": "^8.3|^9.0|^10.5|^11.5.3", + "vimeo/psalm": "^4.0|^5.22|^6.7" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Qirolab\\Theme\\ThemeServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Qirolab\\Theme\\": "src", + "Qirolab\\Theme\\Database\\Factories\\": "database/factories" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Harish Kumar", + "email": "harish@qirolab.com", + "homepage": "https://qirolab.com", + "role": "Developer" + } + ], + "description": "A Laravel theme manager, that will help you organize and maintain your themes inside Laravel projects.", + "homepage": "https://qirolab.com", + "keywords": [ + "laravel", + "laravel-theme", + "qirolab", + "theme" + ], + "support": { + "issues": "https://github.com/qirolab/laravel-themer/issues", + "source": "https://github.com/qirolab/laravel-themer/tree/2.4.0" + }, + "funding": [ + { + "url": "https://www.buymeacoffee.com/qirolab", + "type": "other" + } + ], + "time": "2025-02-26T08:51:08+00:00" + }, + { + "name": "ralouphie/getallheaders", + "version": "3.0.3", + "source": { + "type": "git", + "url": "https://github.com/ralouphie/getallheaders.git", + "reference": "120b605dfeb996808c31b6477290a714d356e822" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ralouphie/getallheaders/zipball/120b605dfeb996808c31b6477290a714d356e822", + "reference": "120b605dfeb996808c31b6477290a714d356e822", + "shasum": "" + }, + "require": { + "php": ">=5.6" + }, + "require-dev": { + "php-coveralls/php-coveralls": "^2.1", + "phpunit/phpunit": "^5 || ^6.5" + }, + "type": "library", + "autoload": { + "files": [ + "src/getallheaders.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ralph Khattar", + "email": "ralph.khattar@gmail.com" + } + ], + "description": "A polyfill for getallheaders.", + "support": { + "issues": "https://github.com/ralouphie/getallheaders/issues", + "source": "https://github.com/ralouphie/getallheaders/tree/develop" + }, + "time": "2019-03-08T08:55:37+00:00" + }, + { + "name": "ramsey/collection", + "version": "2.1.1", + "source": { + "type": "git", + "url": "https://github.com/ramsey/collection.git", + "reference": "344572933ad0181accbf4ba763e85a0306a8c5e2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ramsey/collection/zipball/344572933ad0181accbf4ba763e85a0306a8c5e2", + "reference": "344572933ad0181accbf4ba763e85a0306a8c5e2", + "shasum": "" + }, + "require": { + "php": "^8.1" + }, + "require-dev": { + "captainhook/plugin-composer": "^5.3", + "ergebnis/composer-normalize": "^2.45", + "fakerphp/faker": "^1.24", + "hamcrest/hamcrest-php": "^2.0", + "jangregor/phpstan-prophecy": "^2.1", + "mockery/mockery": "^1.6", + "php-parallel-lint/php-console-highlighter": "^1.0", + "php-parallel-lint/php-parallel-lint": "^1.4", + "phpspec/prophecy-phpunit": "^2.3", + "phpstan/extension-installer": "^1.4", + "phpstan/phpstan": "^2.1", + "phpstan/phpstan-mockery": "^2.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpunit/phpunit": "^10.5", + "ramsey/coding-standard": "^2.3", + "ramsey/conventional-commits": "^1.6", + "roave/security-advisories": "dev-latest" + }, + "type": "library", + "extra": { + "captainhook": { + "force-install": true + }, + "ramsey/conventional-commits": { + "configFile": "conventional-commits.json" + } + }, + "autoload": { + "psr-4": { + "Ramsey\\Collection\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ben Ramsey", + "email": "ben@benramsey.com", + "homepage": "https://benramsey.com" + } + ], + "description": "A PHP library for representing and manipulating collections.", + "keywords": [ + "array", + "collection", + "hash", + "map", + "queue", + "set" + ], + "support": { + "issues": "https://github.com/ramsey/collection/issues", + "source": "https://github.com/ramsey/collection/tree/2.1.1" + }, + "time": "2025-03-22T05:38:12+00:00" + }, + { + "name": "ramsey/uuid", + "version": "4.9.0", + "source": { + "type": "git", + "url": "https://github.com/ramsey/uuid.git", + "reference": "4e0e23cc785f0724a0e838279a9eb03f28b092a0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ramsey/uuid/zipball/4e0e23cc785f0724a0e838279a9eb03f28b092a0", + "reference": "4e0e23cc785f0724a0e838279a9eb03f28b092a0", + "shasum": "" + }, + "require": { + "brick/math": "^0.8.8 || ^0.9 || ^0.10 || ^0.11 || ^0.12 || ^0.13", + "php": "^8.0", + "ramsey/collection": "^1.2 || ^2.0" + }, + "replace": { + "rhumsaa/uuid": "self.version" + }, + "require-dev": { + "captainhook/captainhook": "^5.25", + "captainhook/plugin-composer": "^5.3", + "dealerdirect/phpcodesniffer-composer-installer": "^1.0", + "ergebnis/composer-normalize": "^2.47", + "mockery/mockery": "^1.6", + "paragonie/random-lib": "^2", + "php-mock/php-mock": "^2.6", + "php-mock/php-mock-mockery": "^1.5", + "php-parallel-lint/php-parallel-lint": "^1.4.0", + "phpbench/phpbench": "^1.2.14", + "phpstan/extension-installer": "^1.4", + "phpstan/phpstan": "^2.1", + "phpstan/phpstan-mockery": "^2.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpunit/phpunit": "^9.6", + "slevomat/coding-standard": "^8.18", + "squizlabs/php_codesniffer": "^3.13" + }, + "suggest": { + "ext-bcmath": "Enables faster math with arbitrary-precision integers using BCMath.", + "ext-gmp": "Enables faster math with arbitrary-precision integers using GMP.", + "ext-uuid": "Enables the use of PeclUuidTimeGenerator and PeclUuidRandomGenerator.", + "paragonie/random-lib": "Provides RandomLib for use with the RandomLibAdapter", + "ramsey/uuid-doctrine": "Allows the use of Ramsey\\Uuid\\Uuid as Doctrine field type." + }, + "type": "library", + "extra": { + "captainhook": { + "force-install": true + } + }, + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "Ramsey\\Uuid\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "A PHP library for generating and working with universally unique identifiers (UUIDs).", + "keywords": [ + "guid", + "identifier", + "uuid" + ], + "support": { + "issues": "https://github.com/ramsey/uuid/issues", + "source": "https://github.com/ramsey/uuid/tree/4.9.0" + }, + "time": "2025-06-25T14:20:11+00:00" + }, + { + "name": "robthree/twofactorauth", + "version": "v3.0.2", + "source": { + "type": "git", + "url": "https://github.com/RobThree/TwoFactorAuth.git", + "reference": "6d70f9ca8e25568f163a7b3b3ff77bd8ea743978" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/RobThree/TwoFactorAuth/zipball/6d70f9ca8e25568f163a7b3b3ff77bd8ea743978", + "reference": "6d70f9ca8e25568f163a7b3b3ff77bd8ea743978", + "shasum": "" + }, + "require": { + "php": ">=8.2.0" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^3.13", + "phpstan/phpstan": "^1.9", + "phpunit/phpunit": "^9" + }, + "suggest": { + "bacon/bacon-qr-code": "Needed for BaconQrCodeProvider provider", + "endroid/qr-code": "Needed for EndroidQrCodeProvider" + }, + "type": "library", + "autoload": { + "psr-4": { + "RobThree\\Auth\\": "lib" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Rob Janssen", + "homepage": "http://robiii.me", + "role": "Developer" + }, + { + "name": "Nicolas CARPi", + "homepage": "https://github.com/NicolasCARPi", + "role": "Developer" + }, + { + "name": "Will Power", + "homepage": "https://github.com/willpower232", + "role": "Developer" + } + ], + "description": "Two Factor Authentication", + "homepage": "https://github.com/RobThree/TwoFactorAuth", + "keywords": [ + "Authentication", + "MFA", + "Multi Factor Authentication", + "Two Factor Authentication", + "authenticator", + "authy", + "php", + "tfa" + ], + "support": { + "issues": "https://github.com/RobThree/TwoFactorAuth/issues", + "source": "https://github.com/RobThree/TwoFactorAuth" + }, + "funding": [ + { + "url": "https://paypal.me/robiii", + "type": "custom" + }, + { + "url": "https://github.com/RobThree", + "type": "github" + } + ], + "time": "2024-10-24T15:14:25+00:00" + }, + { + "name": "ryangjchandler/blade-capture-directive", + "version": "v1.1.0", + "source": { + "type": "git", + "url": "https://github.com/ryangjchandler/blade-capture-directive.git", + "reference": "bbb1513dfd89eaec87a47fe0c449a7e3d4a1976d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ryangjchandler/blade-capture-directive/zipball/bbb1513dfd89eaec87a47fe0c449a7e3d4a1976d", + "reference": "bbb1513dfd89eaec87a47fe0c449a7e3d4a1976d", + "shasum": "" + }, + "require": { + "illuminate/contracts": "^10.0|^11.0|^12.0", + "php": "^8.1", + "spatie/laravel-package-tools": "^1.9.2" + }, + "require-dev": { + "nunomaduro/collision": "^7.0|^8.0", + "nunomaduro/larastan": "^2.0|^3.0", + "orchestra/testbench": "^8.0|^9.0|^10.0", + "pestphp/pest": "^2.0|^3.7", + "pestphp/pest-plugin-laravel": "^2.0|^3.1", + "phpstan/extension-installer": "^1.1", + "phpstan/phpstan-deprecation-rules": "^1.0|^2.0", + "phpstan/phpstan-phpunit": "^1.0|^2.0", + "phpunit/phpunit": "^10.0|^11.5.3", + "spatie/laravel-ray": "^1.26" + }, + "type": "library", + "extra": { + "laravel": { + "aliases": { + "BladeCaptureDirective": "RyanChandler\\BladeCaptureDirective\\Facades\\BladeCaptureDirective" + }, + "providers": [ + "RyanChandler\\BladeCaptureDirective\\BladeCaptureDirectiveServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "RyanChandler\\BladeCaptureDirective\\": "src", + "RyanChandler\\BladeCaptureDirective\\Database\\Factories\\": "database/factories" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ryan Chandler", + "email": "support@ryangjchandler.co.uk", + "role": "Developer" + } + ], + "description": "Create inline partials in your Blade templates with ease.", + "homepage": "https://github.com/ryangjchandler/blade-capture-directive", + "keywords": [ + "blade-capture-directive", + "laravel", + "ryangjchandler" + ], + "support": { + "issues": "https://github.com/ryangjchandler/blade-capture-directive/issues", + "source": "https://github.com/ryangjchandler/blade-capture-directive/tree/v1.1.0" + }, + "funding": [ + { + "url": "https://github.com/ryangjchandler", + "type": "github" + } + ], + "time": "2025-02-25T09:09:36+00:00" + }, + { + "name": "sabberworm/php-css-parser", + "version": "v8.9.0", + "source": { + "type": "git", + "url": "https://github.com/MyIntervals/PHP-CSS-Parser.git", + "reference": "d8e916507b88e389e26d4ab03c904a082aa66bb9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/MyIntervals/PHP-CSS-Parser/zipball/d8e916507b88e389e26d4ab03c904a082aa66bb9", + "reference": "d8e916507b88e389e26d4ab03c904a082aa66bb9", + "shasum": "" + }, + "require": { + "ext-iconv": "*", + "php": "^5.6.20 || ^7.0.0 || ~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0" + }, + "require-dev": { + "phpunit/phpunit": "5.7.27 || 6.5.14 || 7.5.20 || 8.5.41", + "rawr/cross-data-providers": "^2.0.0" + }, + "suggest": { + "ext-mbstring": "for parsing UTF-8 CSS" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "9.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Sabberworm\\CSS\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Raphael Schweikert" + }, + { + "name": "Oliver Klee", + "email": "github@oliverklee.de" + }, + { + "name": "Jake Hotson", + "email": "jake.github@qzdesign.co.uk" + } + ], + "description": "Parser for CSS Files written in PHP", + "homepage": "https://www.sabberworm.com/blog/2010/6/10/php-css-parser", + "keywords": [ + "css", + "parser", + "stylesheet" + ], + "support": { + "issues": "https://github.com/MyIntervals/PHP-CSS-Parser/issues", + "source": "https://github.com/MyIntervals/PHP-CSS-Parser/tree/v8.9.0" + }, + "time": "2025-07-11T13:20:48+00:00" + }, + { + "name": "socialiteproviders/discord", + "version": "4.2.0", + "source": { + "type": "git", + "url": "https://github.com/SocialiteProviders/Discord.git", + "reference": "c71c379acfdca5ba4aa65a3db5ae5222852a919c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/SocialiteProviders/Discord/zipball/c71c379acfdca5ba4aa65a3db5ae5222852a919c", + "reference": "c71c379acfdca5ba4aa65a3db5ae5222852a919c", + "shasum": "" + }, + "require": { + "ext-json": "*", + "php": "^7.4 || ^8.0", + "socialiteproviders/manager": "~4.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "SocialiteProviders\\Discord\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christopher Eklund", + "email": "eklundchristopher@gmail.com" + } + ], + "description": "Discord OAuth2 Provider for Laravel Socialite", + "keywords": [ + "discord", + "laravel", + "oauth", + "provider", + "socialite" + ], + "support": { + "docs": "https://socialiteproviders.com/discord", + "issues": "https://github.com/socialiteproviders/providers/issues", + "source": "https://github.com/socialiteproviders/providers" + }, + "time": "2023-07-24T23:28:47+00:00" + }, + { + "name": "socialiteproviders/manager", + "version": "v4.8.1", + "source": { + "type": "git", + "url": "https://github.com/SocialiteProviders/Manager.git", + "reference": "8180ec14bef230ec2351cff993d5d2d7ca470ef4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/SocialiteProviders/Manager/zipball/8180ec14bef230ec2351cff993d5d2d7ca470ef4", + "reference": "8180ec14bef230ec2351cff993d5d2d7ca470ef4", + "shasum": "" + }, + "require": { + "illuminate/support": "^8.0 || ^9.0 || ^10.0 || ^11.0 || ^12.0", + "laravel/socialite": "^5.5", + "php": "^8.1" + }, + "require-dev": { + "mockery/mockery": "^1.2", + "phpunit/phpunit": "^9.0" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "SocialiteProviders\\Manager\\ServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "SocialiteProviders\\Manager\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Andy Wendt", + "email": "andy@awendt.com" + }, + { + "name": "Anton Komarev", + "email": "a.komarev@cybercog.su" + }, + { + "name": "Miguel Piedrafita", + "email": "soy@miguelpiedrafita.com" + }, + { + "name": "atymic", + "email": "atymicq@gmail.com", + "homepage": "https://atymic.dev" + } + ], + "description": "Easily add new or override built-in providers in Laravel Socialite.", + "homepage": "https://socialiteproviders.com", + "keywords": [ + "laravel", + "manager", + "oauth", + "providers", + "socialite" + ], + "support": { + "issues": "https://github.com/socialiteproviders/manager/issues", + "source": "https://github.com/socialiteproviders/manager" + }, + "time": "2025-02-24T19:33:30+00:00" + }, + { + "name": "spatie/color", + "version": "1.8.0", + "source": { + "type": "git", + "url": "https://github.com/spatie/color.git", + "reference": "142af7fec069a420babea80a5412eb2f646dcd8c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/spatie/color/zipball/142af7fec069a420babea80a5412eb2f646dcd8c", + "reference": "142af7fec069a420babea80a5412eb2f646dcd8c", + "shasum": "" + }, + "require": { + "php": "^7.3|^8.0" + }, + "require-dev": { + "pestphp/pest": "^1.22", + "phpunit/phpunit": "^6.5||^9.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Spatie\\Color\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Sebastian De Deyne", + "email": "sebastian@spatie.be", + "homepage": "https://spatie.be", + "role": "Developer" + } + ], + "description": "A little library to handle color conversions", + "homepage": "https://github.com/spatie/color", + "keywords": [ + "color", + "conversion", + "rgb", + "spatie" + ], + "support": { + "issues": "https://github.com/spatie/color/issues", + "source": "https://github.com/spatie/color/tree/1.8.0" + }, + "funding": [ + { + "url": "https://github.com/spatie", + "type": "github" + } + ], + "time": "2025-02-10T09:22:41+00:00" + }, + { + "name": "spatie/invade", + "version": "2.1.0", + "source": { + "type": "git", + "url": "https://github.com/spatie/invade.git", + "reference": "b920f6411d21df4e8610a138e2e87ae4957d7f63" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/spatie/invade/zipball/b920f6411d21df4e8610a138e2e87ae4957d7f63", + "reference": "b920f6411d21df4e8610a138e2e87ae4957d7f63", + "shasum": "" + }, + "require": { + "php": "^8.0" + }, + "require-dev": { + "pestphp/pest": "^1.20", + "phpstan/phpstan": "^1.4", + "spatie/ray": "^1.28" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "Spatie\\Invade\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Freek Van der Herten", + "email": "freek@spatie.be", + "role": "Developer" + } + ], + "description": "A PHP function to work with private properties and methods", + "homepage": "https://github.com/spatie/invade", + "keywords": [ + "invade", + "spatie" + ], + "support": { + "source": "https://github.com/spatie/invade/tree/2.1.0" + }, + "funding": [ + { + "url": "https://github.com/spatie", + "type": "github" + } + ], + "time": "2024-05-17T09:06:10+00:00" + }, + { + "name": "spatie/laravel-cookie-consent", + "version": "3.3.3", + "source": { + "type": "git", + "url": "https://github.com/spatie/laravel-cookie-consent.git", + "reference": "fa375d4c9cae8745137ec3249e1b8e35d911963a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/spatie/laravel-cookie-consent/zipball/fa375d4c9cae8745137ec3249e1b8e35d911963a", + "reference": "fa375d4c9cae8745137ec3249e1b8e35d911963a", + "shasum": "" + }, + "require": { + "illuminate/cookie": "^10.0|^11.0|^12.0", + "illuminate/support": "^10.0|^11.0|^12.0", + "illuminate/view": "^10.0|^11.0|^12.0", + "php": "^8.2", + "spatie/laravel-package-tools": "^1.9" + }, + "require-dev": { + "fakerphp/faker": "^1.9", + "orchestra/testbench": "^8.0|^9.0|^10.0", + "pestphp/pest": "^2.34|^3.7" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Spatie\\CookieConsent\\CookieConsentServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Spatie\\CookieConsent\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Freek Van der Herten", + "email": "freek@spatie.be", + "homepage": "https://spatie.be", + "role": "Developer" + }, + { + "name": "Willem Van Bockstal", + "email": "willem@spatie.be", + "homepage": "https://spatie.be", + "role": "Developer" + } + ], + "description": "Make your Laravel app comply with the crazy EU cookie law", + "homepage": "https://github.com/spatie/cookie-consent", + "keywords": [ + "comply", + "cookie", + "cookie-consent", + "eu", + "law", + "spatie" + ], + "support": { + "source": "https://github.com/spatie/laravel-cookie-consent/tree/3.3.3" + }, + "funding": [ + { + "url": "https://spatie.be/open-source/support-us", + "type": "custom" + } + ], + "time": "2025-02-21T13:11:14+00:00" + }, + { + "name": "spatie/laravel-package-tools", + "version": "1.92.7", + "source": { + "type": "git", + "url": "https://github.com/spatie/laravel-package-tools.git", + "reference": "f09a799850b1ed765103a4f0b4355006360c49a5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/spatie/laravel-package-tools/zipball/f09a799850b1ed765103a4f0b4355006360c49a5", + "reference": "f09a799850b1ed765103a4f0b4355006360c49a5", + "shasum": "" + }, + "require": { + "illuminate/contracts": "^9.28|^10.0|^11.0|^12.0", + "php": "^8.0" + }, + "require-dev": { + "mockery/mockery": "^1.5", + "orchestra/testbench": "^7.7|^8.0|^9.0|^10.0", + "pestphp/pest": "^1.23|^2.1|^3.1", + "phpunit/php-code-coverage": "^9.0|^10.0|^11.0", + "phpunit/phpunit": "^9.5.24|^10.5|^11.5", + "spatie/pest-plugin-test-time": "^1.1|^2.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "Spatie\\LaravelPackageTools\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Freek Van der Herten", + "email": "freek@spatie.be", + "role": "Developer" + } + ], + "description": "Tools for creating Laravel packages", + "homepage": "https://github.com/spatie/laravel-package-tools", + "keywords": [ + "laravel-package-tools", + "spatie" + ], + "support": { + "issues": "https://github.com/spatie/laravel-package-tools/issues", + "source": "https://github.com/spatie/laravel-package-tools/tree/1.92.7" + }, + "funding": [ + { + "url": "https://github.com/spatie", + "type": "github" + } + ], + "time": "2025-07-17T15:46:43+00:00" + }, + { + "name": "spatie/laravel-query-builder", + "version": "6.3.3", + "source": { + "type": "git", + "url": "https://github.com/spatie/laravel-query-builder.git", + "reference": "0d80323d2b2ffc410f06bf73c2e3a6ad763e4b8d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/spatie/laravel-query-builder/zipball/0d80323d2b2ffc410f06bf73c2e3a6ad763e4b8d", + "reference": "0d80323d2b2ffc410f06bf73c2e3a6ad763e4b8d", + "shasum": "" + }, + "require": { + "illuminate/database": "^10.0|^11.0|^12.0", + "illuminate/http": "^10.0|^11.0|^12.0", + "illuminate/support": "^10.0|^11.0|^12.0", + "php": "^8.2", + "spatie/laravel-package-tools": "^1.11" + }, + "require-dev": { + "ext-json": "*", + "larastan/larastan": "^2.7 || ^3.3", + "mockery/mockery": "^1.4", + "orchestra/testbench": "^7.0|^8.0|^10.0", + "pestphp/pest": "^2.0|^3.7", + "phpunit/phpunit": "^10.0|^11.5.3", + "spatie/invade": "^2.0" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Spatie\\QueryBuilder\\QueryBuilderServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Spatie\\QueryBuilder\\": "src", + "Spatie\\QueryBuilder\\Database\\Factories\\": "database/factories" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Alex Vanderbist", + "email": "alex@spatie.be", + "homepage": "https://spatie.be", + "role": "Developer" + } + ], + "description": "Easily build Eloquent queries from API requests", + "homepage": "https://github.com/spatie/laravel-query-builder", + "keywords": [ + "laravel-query-builder", + "spatie" + ], + "support": { + "issues": "https://github.com/spatie/laravel-query-builder/issues", + "source": "https://github.com/spatie/laravel-query-builder" + }, + "funding": [ + { + "url": "https://spatie.be/open-source/support-us", + "type": "custom" + } + ], + "time": "2025-07-14T08:31:42+00:00" + }, + { + "name": "symfony/clock", + "version": "v7.3.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/clock.git", + "reference": "b81435fbd6648ea425d1ee96a2d8e68f4ceacd24" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/clock/zipball/b81435fbd6648ea425d1ee96a2d8e68f4ceacd24", + "reference": "b81435fbd6648ea425d1ee96a2d8e68f4ceacd24", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "psr/clock": "^1.0", + "symfony/polyfill-php83": "^1.28" + }, + "provide": { + "psr/clock-implementation": "1.0" + }, + "type": "library", + "autoload": { + "files": [ + "Resources/now.php" + ], + "psr-4": { + "Symfony\\Component\\Clock\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Decouples applications from the system clock", + "homepage": "https://symfony.com", + "keywords": [ + "clock", + "psr20", + "time" + ], + "support": { + "source": "https://github.com/symfony/clock/tree/v7.3.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-09-25T14:21:43+00:00" + }, + { + "name": "symfony/console", + "version": "v7.3.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/console.git", + "reference": "9e27aecde8f506ba0fd1d9989620c04a87697101" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/console/zipball/9e27aecde8f506ba0fd1d9989620c04a87697101", + "reference": "9e27aecde8f506ba0fd1d9989620c04a87697101", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-mbstring": "~1.0", + "symfony/service-contracts": "^2.5|^3", + "symfony/string": "^7.2" + }, + "conflict": { + "symfony/dependency-injection": "<6.4", + "symfony/dotenv": "<6.4", + "symfony/event-dispatcher": "<6.4", + "symfony/lock": "<6.4", + "symfony/process": "<6.4" + }, + "provide": { + "psr/log-implementation": "1.0|2.0|3.0" + }, + "require-dev": { + "psr/log": "^1|^2|^3", + "symfony/config": "^6.4|^7.0", + "symfony/dependency-injection": "^6.4|^7.0", + "symfony/event-dispatcher": "^6.4|^7.0", + "symfony/http-foundation": "^6.4|^7.0", + "symfony/http-kernel": "^6.4|^7.0", + "symfony/lock": "^6.4|^7.0", + "symfony/messenger": "^6.4|^7.0", + "symfony/process": "^6.4|^7.0", + "symfony/stopwatch": "^6.4|^7.0", + "symfony/var-dumper": "^6.4|^7.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Console\\": "" + }, + "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": "Eases the creation of beautiful and testable command line interfaces", + "homepage": "https://symfony.com", + "keywords": [ + "cli", + "command-line", + "console", + "terminal" + ], + "support": { + "source": "https://github.com/symfony/console/tree/v7.3.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-06-27T19:55:54+00:00" + }, + { + "name": "symfony/css-selector", + "version": "v7.3.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/css-selector.git", + "reference": "601a5ce9aaad7bf10797e3663faefce9e26c24e2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/css-selector/zipball/601a5ce9aaad7bf10797e3663faefce9e26c24e2", + "reference": "601a5ce9aaad7bf10797e3663faefce9e26c24e2", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\CssSelector\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Jean-François Simon", + "email": "jeanfrancois.simon@sensiolabs.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Converts CSS selectors to XPath expressions", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/css-selector/tree/v7.3.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-09-25T14:21:43+00:00" + }, + { + "name": "symfony/deprecation-contracts", + "version": "v3.6.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/deprecation-contracts.git", + "reference": "63afe740e99a13ba87ec199bb07bbdee937a5b62" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/63afe740e99a13ba87ec199bb07bbdee937a5b62", + "reference": "63afe740e99a13ba87ec199bb07bbdee937a5b62", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.6-dev" + } + }, + "autoload": { + "files": [ + "function.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "A generic function and convention to trigger deprecation notices", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/deprecation-contracts/tree/v3.6.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-09-25T14:21:43+00:00" + }, + { + "name": "symfony/error-handler", + "version": "v7.3.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/error-handler.git", + "reference": "35b55b166f6752d6aaf21aa042fc5ed280fce235" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/error-handler/zipball/35b55b166f6752d6aaf21aa042fc5ed280fce235", + "reference": "35b55b166f6752d6aaf21aa042fc5ed280fce235", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "psr/log": "^1|^2|^3", + "symfony/var-dumper": "^6.4|^7.0" + }, + "conflict": { + "symfony/deprecation-contracts": "<2.5", + "symfony/http-kernel": "<6.4" + }, + "require-dev": { + "symfony/console": "^6.4|^7.0", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/http-kernel": "^6.4|^7.0", + "symfony/serializer": "^6.4|^7.0", + "symfony/webpack-encore-bundle": "^1.0|^2.0" + }, + "bin": [ + "Resources/bin/patch-type-declarations" + ], + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\ErrorHandler\\": "" + }, + "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 tools to manage errors and ease debugging PHP code", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/error-handler/tree/v7.3.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-06-13T07:48:40+00:00" + }, + { + "name": "symfony/event-dispatcher", + "version": "v7.3.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/event-dispatcher.git", + "reference": "497f73ac996a598c92409b44ac43b6690c4f666d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/497f73ac996a598c92409b44ac43b6690c4f666d", + "reference": "497f73ac996a598c92409b44ac43b6690c4f666d", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/event-dispatcher-contracts": "^2.5|^3" + }, + "conflict": { + "symfony/dependency-injection": "<6.4", + "symfony/service-contracts": "<2.5" + }, + "provide": { + "psr/event-dispatcher-implementation": "1.0", + "symfony/event-dispatcher-implementation": "2.0|3.0" + }, + "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/service-contracts": "^2.5|^3", + "symfony/stopwatch": "^6.4|^7.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\EventDispatcher\\": "" + }, + "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 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.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-04-22T09:11:45+00:00" + }, + { + "name": "symfony/event-dispatcher-contracts", + "version": "v3.6.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/event-dispatcher-contracts.git", + "reference": "59eb412e93815df44f05f342958efa9f46b1e586" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/59eb412e93815df44f05f342958efa9f46b1e586", + "reference": "59eb412e93815df44f05f342958efa9f46b1e586", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "psr/event-dispatcher": "^1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.6-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\EventDispatcher\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to dispatching event", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/event-dispatcher-contracts/tree/v3.6.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-09-25T14:21:43+00:00" + }, + { + "name": "symfony/finder", + "version": "v7.3.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/finder.git", + "reference": "ec2344cf77a48253bbca6939aa3d2477773ea63d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/finder/zipball/ec2344cf77a48253bbca6939aa3d2477773ea63d", + "reference": "ec2344cf77a48253bbca6939aa3d2477773ea63d", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "symfony/filesystem": "^6.4|^7.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Finder\\": "" + }, + "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": "Finds files and directories via an intuitive fluent interface", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/finder/tree/v7.3.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-12-30T19:00:26+00:00" + }, + { + "name": "symfony/html-sanitizer", + "version": "v7.3.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/html-sanitizer.git", + "reference": "cf21254e982b12276329940ca4af5e623ee06c58" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/html-sanitizer/zipball/cf21254e982b12276329940ca4af5e623ee06c58", + "reference": "cf21254e982b12276329940ca4af5e623ee06c58", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "league/uri": "^6.5|^7.0", + "masterminds/html5": "^2.7.2", + "php": ">=8.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\HtmlSanitizer\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Titouan Galopin", + "email": "galopintitouan@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides an object-oriented API to sanitize untrusted HTML input for safe insertion into a document's DOM.", + "homepage": "https://symfony.com", + "keywords": [ + "Purifier", + "html", + "sanitizer" + ], + "support": { + "source": "https://github.com/symfony/html-sanitizer/tree/v7.3.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-03-31T08:49:55+00:00" + }, + { + "name": "symfony/http-foundation", + "version": "v7.3.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/http-foundation.git", + "reference": "23dd60256610c86a3414575b70c596e5deff6ed9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/http-foundation/zipball/23dd60256610c86a3414575b70c596e5deff6ed9", + "reference": "23dd60256610c86a3414575b70c596e5deff6ed9", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3.0", + "symfony/polyfill-mbstring": "~1.1", + "symfony/polyfill-php83": "^1.27" + }, + "conflict": { + "doctrine/dbal": "<3.6", + "symfony/cache": "<6.4.12|>=7.0,<7.1.5" + }, + "require-dev": { + "doctrine/dbal": "^3.6|^4", + "predis/predis": "^1.1|^2.0", + "symfony/cache": "^6.4.12|^7.1.5", + "symfony/clock": "^6.4|^7.0", + "symfony/dependency-injection": "^6.4|^7.0", + "symfony/expression-language": "^6.4|^7.0", + "symfony/http-kernel": "^6.4|^7.0", + "symfony/mime": "^6.4|^7.0", + "symfony/rate-limiter": "^6.4|^7.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\HttpFoundation\\": "" + }, + "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": "Defines an object-oriented layer for the HTTP specification", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/http-foundation/tree/v7.3.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-06-23T15:07:14+00:00" + }, + { + "name": "symfony/http-kernel", + "version": "v7.3.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/http-kernel.git", + "reference": "1644879a66e4aa29c36fe33dfa6c54b450ce1831" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/http-kernel/zipball/1644879a66e4aa29c36fe33dfa6c54b450ce1831", + "reference": "1644879a66e4aa29c36fe33dfa6c54b450ce1831", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "psr/log": "^1|^2|^3", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/error-handler": "^6.4|^7.0", + "symfony/event-dispatcher": "^7.3", + "symfony/http-foundation": "^7.3", + "symfony/polyfill-ctype": "^1.8" + }, + "conflict": { + "symfony/browser-kit": "<6.4", + "symfony/cache": "<6.4", + "symfony/config": "<6.4", + "symfony/console": "<6.4", + "symfony/dependency-injection": "<6.4", + "symfony/doctrine-bridge": "<6.4", + "symfony/form": "<6.4", + "symfony/http-client": "<6.4", + "symfony/http-client-contracts": "<2.5", + "symfony/mailer": "<6.4", + "symfony/messenger": "<6.4", + "symfony/translation": "<6.4", + "symfony/translation-contracts": "<2.5", + "symfony/twig-bridge": "<6.4", + "symfony/validator": "<6.4", + "symfony/var-dumper": "<6.4", + "twig/twig": "<3.12" + }, + "provide": { + "psr/log-implementation": "1.0|2.0|3.0" + }, + "require-dev": { + "psr/cache": "^1.0|^2.0|^3.0", + "symfony/browser-kit": "^6.4|^7.0", + "symfony/clock": "^6.4|^7.0", + "symfony/config": "^6.4|^7.0", + "symfony/console": "^6.4|^7.0", + "symfony/css-selector": "^6.4|^7.0", + "symfony/dependency-injection": "^6.4|^7.0", + "symfony/dom-crawler": "^6.4|^7.0", + "symfony/expression-language": "^6.4|^7.0", + "symfony/finder": "^6.4|^7.0", + "symfony/http-client-contracts": "^2.5|^3", + "symfony/process": "^6.4|^7.0", + "symfony/property-access": "^7.1", + "symfony/routing": "^6.4|^7.0", + "symfony/serializer": "^7.1", + "symfony/stopwatch": "^6.4|^7.0", + "symfony/translation": "^6.4|^7.0", + "symfony/translation-contracts": "^2.5|^3", + "symfony/uid": "^6.4|^7.0", + "symfony/validator": "^6.4|^7.0", + "symfony/var-dumper": "^6.4|^7.0", + "symfony/var-exporter": "^6.4|^7.0", + "twig/twig": "^3.12" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\HttpKernel\\": "" + }, + "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 a structured process for converting a Request into a Response", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/http-kernel/tree/v7.3.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-06-28T08:24:55+00:00" + }, + { + "name": "symfony/mailer", + "version": "v7.3.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/mailer.git", + "reference": "b5db5105b290bdbea5ab27b89c69effcf1cb3368" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/mailer/zipball/b5db5105b290bdbea5ab27b89c69effcf1cb3368", + "reference": "b5db5105b290bdbea5ab27b89c69effcf1cb3368", + "shasum": "" + }, + "require": { + "egulias/email-validator": "^2.1.10|^3|^4", + "php": ">=8.2", + "psr/event-dispatcher": "^1", + "psr/log": "^1|^2|^3", + "symfony/event-dispatcher": "^6.4|^7.0", + "symfony/mime": "^7.2", + "symfony/service-contracts": "^2.5|^3" + }, + "conflict": { + "symfony/http-client-contracts": "<2.5", + "symfony/http-kernel": "<6.4", + "symfony/messenger": "<6.4", + "symfony/mime": "<6.4", + "symfony/twig-bridge": "<6.4" + }, + "require-dev": { + "symfony/console": "^6.4|^7.0", + "symfony/http-client": "^6.4|^7.0", + "symfony/messenger": "^6.4|^7.0", + "symfony/twig-bridge": "^6.4|^7.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Mailer\\": "" + }, + "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": "Helps sending emails", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/mailer/tree/v7.3.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-06-27T19:55:54+00:00" + }, + { + "name": "symfony/mime", + "version": "v7.3.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/mime.git", + "reference": "0e7b19b2f399c31df0cdbe5d8cbf53f02f6cfcd9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/mime/zipball/0e7b19b2f399c31df0cdbe5d8cbf53f02f6cfcd9", + "reference": "0e7b19b2f399c31df0cdbe5d8cbf53f02f6cfcd9", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/polyfill-intl-idn": "^1.10", + "symfony/polyfill-mbstring": "^1.0" + }, + "conflict": { + "egulias/email-validator": "~3.0.0", + "phpdocumentor/reflection-docblock": "<3.2.2", + "phpdocumentor/type-resolver": "<1.4.0", + "symfony/mailer": "<6.4", + "symfony/serializer": "<6.4.3|>7.0,<7.0.3" + }, + "require-dev": { + "egulias/email-validator": "^2.1.10|^3.1|^4", + "league/html-to-markdown": "^5.0", + "phpdocumentor/reflection-docblock": "^3.0|^4.0|^5.0", + "symfony/dependency-injection": "^6.4|^7.0", + "symfony/process": "^6.4|^7.0", + "symfony/property-access": "^6.4|^7.0", + "symfony/property-info": "^6.4|^7.0", + "symfony/serializer": "^6.4.3|^7.0.3" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Mime\\": "" + }, + "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": "Allows manipulating MIME messages", + "homepage": "https://symfony.com", + "keywords": [ + "mime", + "mime-type" + ], + "support": { + "source": "https://github.com/symfony/mime/tree/v7.3.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-02-19T08:51:26+00:00" + }, + { + "name": "symfony/polyfill-ctype", + "version": "v1.32.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-ctype.git", + "reference": "a3cc8b044a6ea513310cbd48ef7333b384945638" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/a3cc8b044a6ea513310cbd48ef7333b384945638", + "reference": "a3cc8b044a6ea513310cbd48ef7333b384945638", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "provide": { + "ext-ctype": "*" + }, + "suggest": { + "ext-ctype": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Ctype\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Gert de Pagter", + "email": "BackEndTea@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for ctype functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "ctype", + "polyfill", + "portable" + ], + "support": { + "source": "https://github.com/symfony/polyfill-ctype/tree/v1.32.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-09-09T11:45:10+00:00" + }, + { + "name": "symfony/polyfill-intl-grapheme", + "version": "v1.32.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-grapheme.git", + "reference": "b9123926e3b7bc2f98c02ad54f6a4b02b91a8abe" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/b9123926e3b7bc2f98c02ad54f6a4b02b91a8abe", + "reference": "b9123926e3b7bc2f98c02ad54f6a4b02b91a8abe", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "suggest": { + "ext-intl": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Intl\\Grapheme\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for intl's grapheme_* functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "grapheme", + "intl", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.32.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-09-09T11:45:10+00:00" + }, + { + "name": "symfony/polyfill-intl-idn", + "version": "v1.32.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-idn.git", + "reference": "9614ac4d8061dc257ecc64cba1b140873dce8ad3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-idn/zipball/9614ac4d8061dc257ecc64cba1b140873dce8ad3", + "reference": "9614ac4d8061dc257ecc64cba1b140873dce8ad3", + "shasum": "" + }, + "require": { + "php": ">=7.2", + "symfony/polyfill-intl-normalizer": "^1.10" + }, + "suggest": { + "ext-intl": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Intl\\Idn\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Laurent Bassin", + "email": "laurent@bassin.info" + }, + { + "name": "Trevor Rowbotham", + "email": "trevor.rowbotham@pm.me" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for intl's idn_to_ascii and idn_to_utf8 functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "idn", + "intl", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-intl-idn/tree/v1.32.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-09-10T14:38:51+00:00" + }, + { + "name": "symfony/polyfill-intl-normalizer", + "version": "v1.32.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-normalizer.git", + "reference": "3833d7255cc303546435cb650316bff708a1c75c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/3833d7255cc303546435cb650316bff708a1c75c", + "reference": "3833d7255cc303546435cb650316bff708a1c75c", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "suggest": { + "ext-intl": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Intl\\Normalizer\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for intl's Normalizer class and related functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "intl", + "normalizer", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.32.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-09-09T11:45:10+00:00" + }, + { + "name": "symfony/polyfill-mbstring", + "version": "v1.32.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-mbstring.git", + "reference": "6d857f4d76bd4b343eac26d6b539585d2bc56493" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/6d857f4d76bd4b343eac26d6b539585d2bc56493", + "reference": "6d857f4d76bd4b343eac26d6b539585d2bc56493", + "shasum": "" + }, + "require": { + "ext-iconv": "*", + "php": ">=7.2" + }, + "provide": { + "ext-mbstring": "*" + }, + "suggest": { + "ext-mbstring": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Mbstring\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for the Mbstring extension", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "mbstring", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.32.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-12-23T08:48:59+00:00" + }, + { + "name": "symfony/polyfill-php80", + "version": "v1.32.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php80.git", + "reference": "0cc9dd0f17f61d8131e7df6b84bd344899fe2608" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/0cc9dd0f17f61d8131e7df6b84bd344899fe2608", + "reference": "0cc9dd0f17f61d8131e7df6b84bd344899fe2608", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php80\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ion Bazan", + "email": "ion.bazan@gmail.com" + }, + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.0+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php80/tree/v1.32.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-01-02T08:10:11+00:00" + }, + { + "name": "symfony/polyfill-php83", + "version": "v1.32.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php83.git", + "reference": "2fb86d65e2d424369ad2905e83b236a8805ba491" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php83/zipball/2fb86d65e2d424369ad2905e83b236a8805ba491", + "reference": "2fb86d65e2d424369ad2905e83b236a8805ba491", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php83\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.3+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php83/tree/v1.32.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-09-09T11:45:10+00:00" + }, + { + "name": "symfony/polyfill-uuid", + "version": "v1.32.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-uuid.git", + "reference": "21533be36c24be3f4b1669c4725c7d1d2bab4ae2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-uuid/zipball/21533be36c24be3f4b1669c4725c7d1d2bab4ae2", + "reference": "21533be36c24be3f4b1669c4725c7d1d2bab4ae2", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "provide": { + "ext-uuid": "*" + }, + "suggest": { + "ext-uuid": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Uuid\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Grégoire Pineau", + "email": "lyrixx@lyrixx.info" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for uuid functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "uuid" + ], + "support": { + "source": "https://github.com/symfony/polyfill-uuid/tree/v1.32.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-09-09T11:45:10+00:00" + }, + { + "name": "symfony/process", + "version": "v7.3.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/process.git", + "reference": "40c295f2deb408d5e9d2d32b8ba1dd61e36f05af" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/process/zipball/40c295f2deb408d5e9d2d32b8ba1dd61e36f05af", + "reference": "40c295f2deb408d5e9d2d32b8ba1dd61e36f05af", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Process\\": "" + }, + "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": "Executes commands in sub-processes", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/process/tree/v7.3.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-04-17T09:11:12+00:00" + }, + { + "name": "symfony/psr-http-message-bridge", + "version": "v7.3.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/psr-http-message-bridge.git", + "reference": "03f2f72319e7acaf2a9f6fcbe30ef17eec51594f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/psr-http-message-bridge/zipball/03f2f72319e7acaf2a9f6fcbe30ef17eec51594f", + "reference": "03f2f72319e7acaf2a9f6fcbe30ef17eec51594f", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "psr/http-message": "^1.0|^2.0", + "symfony/http-foundation": "^6.4|^7.0" + }, + "conflict": { + "php-http/discovery": "<1.15", + "symfony/http-kernel": "<6.4" + }, + "require-dev": { + "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" + }, + "type": "symfony-bridge", + "autoload": { + "psr-4": { + "Symfony\\Bridge\\PsrHttpMessage\\": "" + }, + "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": "PSR HTTP message bridge", + "homepage": "https://symfony.com", + "keywords": [ + "http", + "http-message", + "psr-17", + "psr-7" + ], + "support": { + "source": "https://github.com/symfony/psr-http-message-bridge/tree/v7.3.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-09-26T08:57:56+00:00" + }, + { + "name": "symfony/routing", + "version": "v7.3.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/routing.git", + "reference": "8e213820c5fea844ecea29203d2a308019007c15" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/routing/zipball/8e213820c5fea844ecea29203d2a308019007c15", + "reference": "8e213820c5fea844ecea29203d2a308019007c15", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3" + }, + "conflict": { + "symfony/config": "<6.4", + "symfony/dependency-injection": "<6.4", + "symfony/yaml": "<6.4" + }, + "require-dev": { + "psr/log": "^1|^2|^3", + "symfony/config": "^6.4|^7.0", + "symfony/dependency-injection": "^6.4|^7.0", + "symfony/expression-language": "^6.4|^7.0", + "symfony/http-foundation": "^6.4|^7.0", + "symfony/yaml": "^6.4|^7.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Routing\\": "" + }, + "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": "Maps an HTTP request to a set of configuration variables", + "homepage": "https://symfony.com", + "keywords": [ + "router", + "routing", + "uri", + "url" + ], + "support": { + "source": "https://github.com/symfony/routing/tree/v7.3.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-05-24T20:43:28+00:00" + }, + { + "name": "symfony/service-contracts", + "version": "v3.6.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/service-contracts.git", + "reference": "f021b05a130d35510bd6b25fe9053c2a8a15d5d4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/service-contracts/zipball/f021b05a130d35510bd6b25fe9053c2a8a15d5d4", + "reference": "f021b05a130d35510bd6b25fe9053c2a8a15d5d4", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "psr/container": "^1.1|^2.0", + "symfony/deprecation-contracts": "^2.5|^3" + }, + "conflict": { + "ext-psr": "<1.1|>=2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.6-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\Service\\": "" + }, + "exclude-from-classmap": [ + "/Test/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to writing services", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/service-contracts/tree/v3.6.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-04-25T09:37:31+00:00" + }, + { + "name": "symfony/string", + "version": "v7.3.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/string.git", + "reference": "f3570b8c61ca887a9e2938e85cb6458515d2b125" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/string/zipball/f3570b8c61ca887a9e2938e85cb6458515d2b125", + "reference": "f3570b8c61ca887a9e2938e85cb6458515d2b125", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/polyfill-ctype": "~1.8", + "symfony/polyfill-intl-grapheme": "~1.0", + "symfony/polyfill-intl-normalizer": "~1.0", + "symfony/polyfill-mbstring": "~1.0" + }, + "conflict": { + "symfony/translation-contracts": "<2.5" + }, + "require-dev": { + "symfony/emoji": "^7.1", + "symfony/error-handler": "^6.4|^7.0", + "symfony/http-client": "^6.4|^7.0", + "symfony/intl": "^6.4|^7.0", + "symfony/translation-contracts": "^2.5|^3.0", + "symfony/var-exporter": "^6.4|^7.0" + }, + "type": "library", + "autoload": { + "files": [ + "Resources/functions.php" + ], + "psr-4": { + "Symfony\\Component\\String\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides an object-oriented API to strings and deals with bytes, UTF-8 code points and grapheme clusters in a unified way", + "homepage": "https://symfony.com", + "keywords": [ + "grapheme", + "i18n", + "string", + "unicode", + "utf-8", + "utf8" + ], + "support": { + "source": "https://github.com/symfony/string/tree/v7.3.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-04-20T20:19:01+00:00" + }, + { + "name": "symfony/translation", + "version": "v7.3.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/translation.git", + "reference": "241d5ac4910d256660238a7ecf250deba4c73063" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/translation/zipball/241d5ac4910d256660238a7ecf250deba4c73063", + "reference": "241d5ac4910d256660238a7ecf250deba4c73063", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-mbstring": "~1.0", + "symfony/translation-contracts": "^2.5|^3.0" + }, + "conflict": { + "nikic/php-parser": "<5.0", + "symfony/config": "<6.4", + "symfony/console": "<6.4", + "symfony/dependency-injection": "<6.4", + "symfony/http-client-contracts": "<2.5", + "symfony/http-kernel": "<6.4", + "symfony/service-contracts": "<2.5", + "symfony/twig-bundle": "<6.4", + "symfony/yaml": "<6.4" + }, + "provide": { + "symfony/translation-implementation": "2.3|3.0" + }, + "require-dev": { + "nikic/php-parser": "^5.0", + "psr/log": "^1|^2|^3", + "symfony/config": "^6.4|^7.0", + "symfony/console": "^6.4|^7.0", + "symfony/dependency-injection": "^6.4|^7.0", + "symfony/finder": "^6.4|^7.0", + "symfony/http-client-contracts": "^2.5|^3.0", + "symfony/http-kernel": "^6.4|^7.0", + "symfony/intl": "^6.4|^7.0", + "symfony/polyfill-intl-icu": "^1.21", + "symfony/routing": "^6.4|^7.0", + "symfony/service-contracts": "^2.5|^3", + "symfony/yaml": "^6.4|^7.0" + }, + "type": "library", + "autoload": { + "files": [ + "Resources/functions.php" + ], + "psr-4": { + "Symfony\\Component\\Translation\\": "" + }, + "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 tools to internationalize your application", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/translation/tree/v7.3.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-06-27T19:55:54+00:00" + }, + { + "name": "symfony/translation-contracts", + "version": "v3.6.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/translation-contracts.git", + "reference": "df210c7a2573f1913b2d17cc95f90f53a73d8f7d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/translation-contracts/zipball/df210c7a2573f1913b2d17cc95f90f53a73d8f7d", + "reference": "df210c7a2573f1913b2d17cc95f90f53a73d8f7d", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.6-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\Translation\\": "" + }, + "exclude-from-classmap": [ + "/Test/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to translation", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/translation-contracts/tree/v3.6.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-09-27T08:32:26+00:00" + }, + { + "name": "symfony/uid", + "version": "v7.3.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/uid.git", + "reference": "a69f69f3159b852651a6bf45a9fdd149520525bb" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/uid/zipball/a69f69f3159b852651a6bf45a9fdd149520525bb", + "reference": "a69f69f3159b852651a6bf45a9fdd149520525bb", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/polyfill-uuid": "^1.15" + }, + "require-dev": { + "symfony/console": "^6.4|^7.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Uid\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Grégoire Pineau", + "email": "lyrixx@lyrixx.info" + }, + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides an object-oriented API to generate and represent UIDs", + "homepage": "https://symfony.com", + "keywords": [ + "UID", + "ulid", + "uuid" + ], + "support": { + "source": "https://github.com/symfony/uid/tree/v7.3.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-06-27T19:55:54+00:00" + }, + { + "name": "symfony/var-dumper", + "version": "v7.3.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/var-dumper.git", + "reference": "6e209fbe5f5a7b6043baba46fe5735a4b85d0d42" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/var-dumper/zipball/6e209fbe5f5a7b6043baba46fe5735a4b85d0d42", + "reference": "6e209fbe5f5a7b6043baba46fe5735a4b85d0d42", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-mbstring": "~1.0" + }, + "conflict": { + "symfony/console": "<6.4" + }, + "require-dev": { + "ext-iconv": "*", + "symfony/console": "^6.4|^7.0", + "symfony/http-kernel": "^6.4|^7.0", + "symfony/process": "^6.4|^7.0", + "symfony/uid": "^6.4|^7.0", + "twig/twig": "^3.12" + }, + "bin": [ + "Resources/bin/var-dump-server" + ], + "type": "library", + "autoload": { + "files": [ + "Resources/functions/dump.php" + ], + "psr-4": { + "Symfony\\Component\\VarDumper\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides mechanisms for walking through any arbitrary PHP variable", + "homepage": "https://symfony.com", + "keywords": [ + "debug", + "dump" + ], + "support": { + "source": "https://github.com/symfony/var-dumper/tree/v7.3.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-06-27T19:55:54+00:00" + }, + { + "name": "tijsverkoyen/css-to-inline-styles", + "version": "v2.3.0", + "source": { + "type": "git", + "url": "https://github.com/tijsverkoyen/CssToInlineStyles.git", + "reference": "0d72ac1c00084279c1816675284073c5a337c20d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/tijsverkoyen/CssToInlineStyles/zipball/0d72ac1c00084279c1816675284073c5a337c20d", + "reference": "0d72ac1c00084279c1816675284073c5a337c20d", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-libxml": "*", + "php": "^7.4 || ^8.0", + "symfony/css-selector": "^5.4 || ^6.0 || ^7.0" + }, + "require-dev": { + "phpstan/phpstan": "^2.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpunit/phpunit": "^8.5.21 || ^9.5.10" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.x-dev" + } + }, + "autoload": { + "psr-4": { + "TijsVerkoyen\\CssToInlineStyles\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Tijs Verkoyen", + "email": "css_to_inline_styles@verkoyen.eu", + "role": "Developer" + } + ], + "description": "CssToInlineStyles is a class that enables you to convert HTML-pages/files into HTML-pages/files with inline styles. This is very useful when you're sending emails.", + "homepage": "https://github.com/tijsverkoyen/CssToInlineStyles", + "support": { + "issues": "https://github.com/tijsverkoyen/CssToInlineStyles/issues", + "source": "https://github.com/tijsverkoyen/CssToInlineStyles/tree/v2.3.0" + }, + "time": "2024-12-21T16:25:41+00:00" + }, + { + "name": "timacdonald/json-api", + "version": "v1.0.0-beta.9", + "source": { + "type": "git", + "url": "https://github.com/timacdonald/json-api.git", + "reference": "05085f97ed9d785bb218d355cfb1f1cb575e2544" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/timacdonald/json-api/zipball/05085f97ed9d785bb218d355cfb1f1cb575e2544", + "reference": "05085f97ed9d785bb218d355cfb1f1cb575e2544", + "shasum": "" + }, + "require": { + "illuminate/collections": "^11.0 || ^12.0", + "illuminate/database": "^11.0 || ^12.0", + "illuminate/http": "^11.0 || ^12.0", + "illuminate/support": "^11.0 || ^12.0", + "php": "^8.2", + "symfony/http-kernel": "^7.0" + }, + "require-dev": { + "laravel/framework": "^11.0 || ^12.0", + "opis/json-schema": "^2.3", + "orchestra/testbench": "^9.0 || ^10.0", + "phpunit/phpunit": "^9.0 || ^10.5 || ^11.5" + }, + "type": "library", + "autoload": { + "psr-4": { + "TiMacDonald\\JsonApi\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Tim MacDonald", + "email": "hello@timacdonald.me", + "homepage": "https://timacdonald.me" + } + ], + "description": "A Lightweight JSON:API Resource for Laravel", + "keywords": [ + "JSON-API", + "api", + "json", + "laravel", + "resource" + ], + "support": { + "docs": "https://github.com/timacdonald/json-api/blob/master/readme.md", + "issues": "https://github.com/timacdonald/json-api/issues", + "source": "https://github.com/timacdonald/json-api/releases/latest" + }, + "time": "2025-03-30T22:55:25+00:00" + }, + { + "name": "vlucas/phpdotenv", + "version": "v5.6.2", + "source": { + "type": "git", + "url": "https://github.com/vlucas/phpdotenv.git", + "reference": "24ac4c74f91ee2c193fa1aaa5c249cb0822809af" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/vlucas/phpdotenv/zipball/24ac4c74f91ee2c193fa1aaa5c249cb0822809af", + "reference": "24ac4c74f91ee2c193fa1aaa5c249cb0822809af", + "shasum": "" + }, + "require": { + "ext-pcre": "*", + "graham-campbell/result-type": "^1.1.3", + "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" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "ext-filter": "*", + "phpunit/phpunit": "^8.5.34 || ^9.6.13 || ^10.4.2" + }, + "suggest": { + "ext-filter": "Required to use the boolean validator." + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + }, + "branch-alias": { + "dev-master": "5.6-dev" + } + }, + "autoload": { + "psr-4": { + "Dotenv\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Vance Lucas", + "email": "vance@vancelucas.com", + "homepage": "https://github.com/vlucas" + } + ], + "description": "Loads environment variables from `.env` to `getenv()`, `$_ENV` and `$_SERVER` automagically.", + "keywords": [ + "dotenv", + "env", + "environment" + ], + "support": { + "issues": "https://github.com/vlucas/phpdotenv/issues", + "source": "https://github.com/vlucas/phpdotenv/tree/v5.6.2" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/vlucas/phpdotenv", + "type": "tidelift" + } + ], + "time": "2025-04-30T23:37:27+00:00" + }, + { + "name": "voku/portable-ascii", + "version": "2.0.3", + "source": { + "type": "git", + "url": "https://github.com/voku/portable-ascii.git", + "reference": "b1d923f88091c6bf09699efcd7c8a1b1bfd7351d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/voku/portable-ascii/zipball/b1d923f88091c6bf09699efcd7c8a1b1bfd7351d", + "reference": "b1d923f88091c6bf09699efcd7c8a1b1bfd7351d", + "shasum": "" + }, + "require": { + "php": ">=7.0.0" + }, + "require-dev": { + "phpunit/phpunit": "~6.0 || ~7.0 || ~9.0" + }, + "suggest": { + "ext-intl": "Use Intl for transliterator_transliterate() support" + }, + "type": "library", + "autoload": { + "psr-4": { + "voku\\": "src/voku/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Lars Moelleken", + "homepage": "https://www.moelleken.org/" + } + ], + "description": "Portable ASCII library - performance optimized (ascii) string functions for php.", + "homepage": "https://github.com/voku/portable-ascii", + "keywords": [ + "ascii", + "clean", + "php" + ], + "support": { + "issues": "https://github.com/voku/portable-ascii/issues", + "source": "https://github.com/voku/portable-ascii/tree/2.0.3" + }, + "funding": [ + { + "url": "https://www.paypal.me/moelleken", + "type": "custom" + }, + { + "url": "https://github.com/voku", + "type": "github" + }, + { + "url": "https://opencollective.com/portable-ascii", + "type": "open_collective" + }, + { + "url": "https://www.patreon.com/voku", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/voku/portable-ascii", + "type": "tidelift" + } + ], + "time": "2024-11-21T01:49:47+00:00" + }, + { + "name": "webmozart/assert", + "version": "1.11.0", + "source": { + "type": "git", + "url": "https://github.com/webmozarts/assert.git", + "reference": "11cb2199493b2f8a3b53e7f19068fc6aac760991" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/webmozarts/assert/zipball/11cb2199493b2f8a3b53e7f19068fc6aac760991", + "reference": "11cb2199493b2f8a3b53e7f19068fc6aac760991", + "shasum": "" + }, + "require": { + "ext-ctype": "*", + "php": "^7.2 || ^8.0" + }, + "conflict": { + "phpstan/phpstan": "<0.12.20", + "vimeo/psalm": "<4.6.1 || 4.6.2" + }, + "require-dev": { + "phpunit/phpunit": "^8.5.13" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.10-dev" + } + }, + "autoload": { + "psr-4": { + "Webmozart\\Assert\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Bernhard Schussek", + "email": "bschussek@gmail.com" + } + ], + "description": "Assertions to validate method input/output with nice error messages.", + "keywords": [ + "assert", + "check", + "validate" + ], + "support": { + "issues": "https://github.com/webmozarts/assert/issues", + "source": "https://github.com/webmozarts/assert/tree/1.11.0" + }, + "time": "2022-06-03T18:03:27+00:00" + }, + { + "name": "whitecube/laravel-cookie-consent", + "version": "v1.3.6", + "source": { + "type": "git", + "url": "https://github.com/whitecube/laravel-cookie-consent.git", + "reference": "212ebacb20b56d150c7e85ae726269cbb1e344db" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/whitecube/laravel-cookie-consent/zipball/212ebacb20b56d150c7e85ae726269cbb1e344db", + "reference": "212ebacb20b56d150c7e85ae726269cbb1e344db", + "shasum": "" + }, + "require": { + "laravel/framework": "^9|^10|^11|^12", + "php": ">=8.1" + }, + "require-dev": { + "mockery/mockery": "^1.5", + "orchestra/testbench": "^8.5|^9.0|^10.0", + "pestphp/pest": "^2.0|^3.7" + }, + "type": "library", + "extra": { + "laravel": { + "aliases": { + "Cookies": "Whitecube\\LaravelCookieConsent\\Facades\\Cookies" + }, + "providers": [ + "Whitecube\\LaravelCookieConsent\\ServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Whitecube\\LaravelCookieConsent\\": "src/", + "Whitecube\\LaravelCookieConsent\\Tests\\": "tests/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Whitecube", + "email": "hello@whitecube.be" + }, + { + "name": "Toon Van den Bos", + "email": "toon@whitecube.be" + } + ], + "description": "Register, configure and ask for cookies consent in a EU-compliant way.", + "keywords": [ + "compliant", + "cookies", + "eu", + "gdpr", + "laravel", + "whitecube" + ], + "support": { + "issues": "https://github.com/whitecube/laravel-cookie-consent/issues", + "source": "https://github.com/whitecube/laravel-cookie-consent/tree/v1.3.6" + }, + "funding": [ + { + "url": "https://github.com/whitecube", + "type": "github" + } + ], + "time": "2025-09-24T08:48:54+00:00" + } + ], + "packages-dev": [ + { + "name": "barryvdh/laravel-debugbar", + "version": "v3.15.4", + "source": { + "type": "git", + "url": "https://github.com/barryvdh/laravel-debugbar.git", + "reference": "c0667ea91f7185f1e074402c5788195e96bf8106" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/barryvdh/laravel-debugbar/zipball/c0667ea91f7185f1e074402c5788195e96bf8106", + "reference": "c0667ea91f7185f1e074402c5788195e96bf8106", + "shasum": "" + }, + "require": { + "illuminate/routing": "^9|^10|^11|^12", + "illuminate/session": "^9|^10|^11|^12", + "illuminate/support": "^9|^10|^11|^12", + "php": "^8.1", + "php-debugbar/php-debugbar": "~2.1.1", + "symfony/finder": "^6|^7" + }, + "require-dev": { + "mockery/mockery": "^1.3.3", + "orchestra/testbench-dusk": "^7|^8|^9|^10", + "phpunit/phpunit": "^9.5.10|^10|^11", + "squizlabs/php_codesniffer": "^3.5" + }, + "type": "library", + "extra": { + "laravel": { + "aliases": { + "Debugbar": "Barryvdh\\Debugbar\\Facades\\Debugbar" + }, + "providers": [ + "Barryvdh\\Debugbar\\ServiceProvider" + ] + }, + "branch-alias": { + "dev-master": "3.15-dev" + } + }, + "autoload": { + "files": [ + "src/helpers.php" + ], + "psr-4": { + "Barryvdh\\Debugbar\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Barry vd. Heuvel", + "email": "barryvdh@gmail.com" + } + ], + "description": "PHP Debugbar integration for Laravel", + "keywords": [ + "debug", + "debugbar", + "dev", + "laravel", + "profiler", + "webprofiler" + ], + "support": { + "issues": "https://github.com/barryvdh/laravel-debugbar/issues", + "source": "https://github.com/barryvdh/laravel-debugbar/tree/v3.15.4" + }, + "funding": [ + { + "url": "https://fruitcake.nl", + "type": "custom" + }, + { + "url": "https://github.com/barryvdh", + "type": "github" + } + ], + "time": "2025-04-16T06:32:06+00:00" + }, + { + "name": "dedoc/scramble", + "version": "v0.12.23", + "source": { + "type": "git", + "url": "https://github.com/dedoc/scramble.git", + "reference": "5b650167c81c59138e844c2ae550c14dc1a249d0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/dedoc/scramble/zipball/5b650167c81c59138e844c2ae550c14dc1a249d0", + "reference": "5b650167c81c59138e844c2ae550c14dc1a249d0", + "shasum": "" + }, + "require": { + "illuminate/contracts": "^10.0|^11.0|^12.0", + "myclabs/deep-copy": "^1.12", + "nikic/php-parser": "^5.0", + "php": "^8.1", + "phpstan/phpdoc-parser": "^1.0|^2.0", + "spatie/laravel-package-tools": "^1.9.2" + }, + "require-dev": { + "larastan/larastan": "^3.3", + "laravel/pint": "^v1.1.0", + "nunomaduro/collision": "^7.0|^8.0", + "orchestra/testbench": "^8.0|^9.0|^10.0", + "pestphp/pest": "^2.34|^3.7", + "pestphp/pest-plugin-laravel": "^2.3|^3.1", + "phpstan/extension-installer": "^1.4", + "phpstan/phpstan-deprecation-rules": "^2.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpunit/phpunit": "^10.5|^11.5.3", + "spatie/laravel-permission": "^6.10", + "spatie/pest-plugin-snapshots": "^2.1" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Dedoc\\Scramble\\ScrambleServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Dedoc\\Scramble\\": "src", + "Dedoc\\Scramble\\Database\\Factories\\": "database/factories" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Roman Lytvynenko", + "email": "litvinenko95@gmail.com", + "role": "Developer" + } + ], + "description": "Automatic generation of API documentation for Laravel applications.", + "homepage": "https://github.com/dedoc/scramble", + "keywords": [ + "documentation", + "laravel", + "openapi" + ], + "support": { + "issues": "https://github.com/dedoc/scramble/issues", + "source": "https://github.com/dedoc/scramble/tree/v0.12.23" + }, + "funding": [ + { + "url": "https://github.com/romalytvynenko", + "type": "github" + } + ], + "time": "2025-06-15T09:04:49+00:00" + }, + { + "name": "fakerphp/faker", + "version": "v1.24.1", + "source": { + "type": "git", + "url": "https://github.com/FakerPHP/Faker.git", + "reference": "e0ee18eb1e6dc3cda3ce9fd97e5a0689a88a64b5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/FakerPHP/Faker/zipball/e0ee18eb1e6dc3cda3ce9fd97e5a0689a88a64b5", + "reference": "e0ee18eb1e6dc3cda3ce9fd97e5a0689a88a64b5", + "shasum": "" + }, + "require": { + "php": "^7.4 || ^8.0", + "psr/container": "^1.0 || ^2.0", + "symfony/deprecation-contracts": "^2.2 || ^3.0" + }, + "conflict": { + "fzaninotto/faker": "*" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.4.1", + "doctrine/persistence": "^1.3 || ^2.0", + "ext-intl": "*", + "phpunit/phpunit": "^9.5.26", + "symfony/phpunit-bridge": "^5.4.16" + }, + "suggest": { + "doctrine/orm": "Required to use Faker\\ORM\\Doctrine", + "ext-curl": "Required by Faker\\Provider\\Image to download images.", + "ext-dom": "Required by Faker\\Provider\\HtmlLorem for generating random HTML.", + "ext-iconv": "Required by Faker\\Provider\\ru_RU\\Text::realText() for generating real Russian text.", + "ext-mbstring": "Required for multibyte Unicode string functionality." + }, + "type": "library", + "autoload": { + "psr-4": { + "Faker\\": "src/Faker/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "François Zaninotto" + } + ], + "description": "Faker is a PHP library that generates fake data for you.", + "keywords": [ + "data", + "faker", + "fixtures" + ], + "support": { + "issues": "https://github.com/FakerPHP/Faker/issues", + "source": "https://github.com/FakerPHP/Faker/tree/v1.24.1" + }, + "time": "2024-11-21T13:46:39+00:00" + }, + { + "name": "filp/whoops", + "version": "2.18.3", + "source": { + "type": "git", + "url": "https://github.com/filp/whoops.git", + "reference": "59a123a3d459c5a23055802237cb317f609867e5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/filp/whoops/zipball/59a123a3d459c5a23055802237cb317f609867e5", + "reference": "59a123a3d459c5a23055802237cb317f609867e5", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0", + "psr/log": "^1.0.1 || ^2.0 || ^3.0" + }, + "require-dev": { + "mockery/mockery": "^1.0", + "phpunit/phpunit": "^7.5.20 || ^8.5.8 || ^9.3.3", + "symfony/var-dumper": "^4.0 || ^5.0" + }, + "suggest": { + "symfony/var-dumper": "Pretty print complex values better with var-dumper available", + "whoops/soap": "Formats errors as SOAP responses" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.7-dev" + } + }, + "autoload": { + "psr-4": { + "Whoops\\": "src/Whoops/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Filipe Dobreira", + "homepage": "https://github.com/filp", + "role": "Developer" + } + ], + "description": "php error handling for cool kids", + "homepage": "https://filp.github.io/whoops/", + "keywords": [ + "error", + "exception", + "handling", + "library", + "throwable", + "whoops" + ], + "support": { + "issues": "https://github.com/filp/whoops/issues", + "source": "https://github.com/filp/whoops/tree/2.18.3" + }, + "funding": [ + { + "url": "https://github.com/denis-sokolov", + "type": "github" + } + ], + "time": "2025-06-16T00:02:10+00:00" + }, + { + "name": "hamcrest/hamcrest-php", + "version": "v2.1.1", + "source": { + "type": "git", + "url": "https://github.com/hamcrest/hamcrest-php.git", + "reference": "f8b1c0173b22fa6ec77a81fe63e5b01eba7e6487" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/hamcrest/hamcrest-php/zipball/f8b1c0173b22fa6ec77a81fe63e5b01eba7e6487", + "reference": "f8b1c0173b22fa6ec77a81fe63e5b01eba7e6487", + "shasum": "" + }, + "require": { + "php": "^7.4|^8.0" + }, + "replace": { + "cordoval/hamcrest-php": "*", + "davedevelopment/hamcrest-php": "*", + "kodova/hamcrest-php": "*" + }, + "require-dev": { + "phpunit/php-file-iterator": "^1.4 || ^2.0 || ^3.0", + "phpunit/phpunit": "^4.8.36 || ^5.7 || ^6.5 || ^7.0 || ^8.0 || ^9.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.1-dev" + } + }, + "autoload": { + "classmap": [ + "hamcrest" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "description": "This is the PHP port of Hamcrest Matchers", + "keywords": [ + "test" + ], + "support": { + "issues": "https://github.com/hamcrest/hamcrest-php/issues", + "source": "https://github.com/hamcrest/hamcrest-php/tree/v2.1.1" + }, + "time": "2025-04-30T06:54:44+00:00" + }, + { + "name": "iamcal/sql-parser", + "version": "v0.6", + "source": { + "type": "git", + "url": "https://github.com/iamcal/SQLParser.git", + "reference": "947083e2dca211a6f12fb1beb67a01e387de9b62" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/iamcal/SQLParser/zipball/947083e2dca211a6f12fb1beb67a01e387de9b62", + "reference": "947083e2dca211a6f12fb1beb67a01e387de9b62", + "shasum": "" + }, + "require-dev": { + "php-coveralls/php-coveralls": "^1.0", + "phpunit/phpunit": "^5|^6|^7|^8|^9" + }, + "type": "library", + "autoload": { + "psr-4": { + "iamcal\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Cal Henderson", + "email": "cal@iamcal.com" + } + ], + "description": "MySQL schema parser", + "support": { + "issues": "https://github.com/iamcal/SQLParser/issues", + "source": "https://github.com/iamcal/SQLParser/tree/v0.6" + }, + "time": "2025-03-17T16:59:46+00:00" + }, + { + "name": "larastan/larastan", + "version": "v3.6.0", + "source": { + "type": "git", + "url": "https://github.com/larastan/larastan.git", + "reference": "6431d010dd383a9279eb8874a76ddb571738564a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/larastan/larastan/zipball/6431d010dd383a9279eb8874a76ddb571738564a", + "reference": "6431d010dd383a9279eb8874a76ddb571738564a", + "shasum": "" + }, + "require": { + "ext-json": "*", + "iamcal/sql-parser": "^0.6.0", + "illuminate/console": "^11.44.2 || ^12.4.1", + "illuminate/container": "^11.44.2 || ^12.4.1", + "illuminate/contracts": "^11.44.2 || ^12.4.1", + "illuminate/database": "^11.44.2 || ^12.4.1", + "illuminate/http": "^11.44.2 || ^12.4.1", + "illuminate/pipeline": "^11.44.2 || ^12.4.1", + "illuminate/support": "^11.44.2 || ^12.4.1", + "php": "^8.2", + "phpstan/phpstan": "^2.1.11" + }, + "require-dev": { + "doctrine/coding-standard": "^13", + "laravel/framework": "^11.44.2 || ^12.7.2", + "mockery/mockery": "^1.6.12", + "nikic/php-parser": "^5.4", + "orchestra/canvas": "^v9.2.2 || ^10.0.1", + "orchestra/testbench-core": "^9.12.0 || ^10.1", + "phpstan/phpstan-deprecation-rules": "^2.0.1", + "phpunit/phpunit": "^10.5.35 || ^11.5.15" + }, + "suggest": { + "orchestra/testbench": "Using Larastan for analysing a package needs Testbench" + }, + "type": "phpstan-extension", + "extra": { + "phpstan": { + "includes": [ + "extension.neon" + ] + }, + "branch-alias": { + "dev-master": "3.0-dev" + } + }, + "autoload": { + "psr-4": { + "Larastan\\Larastan\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Can Vural", + "email": "can9119@gmail.com" + } + ], + "description": "Larastan - Discover bugs in your code without running it. A phpstan/phpstan extension for Laravel", + "keywords": [ + "PHPStan", + "code analyse", + "code analysis", + "larastan", + "laravel", + "package", + "php", + "static analysis" + ], + "support": { + "issues": "https://github.com/larastan/larastan/issues", + "source": "https://github.com/larastan/larastan/tree/v3.6.0" + }, + "funding": [ + { + "url": "https://github.com/canvural", + "type": "github" + } + ], + "time": "2025-07-11T06:52:52+00:00" + }, + { + "name": "laravel/pint", + "version": "v1.24.0", + "source": { + "type": "git", + "url": "https://github.com/laravel/pint.git", + "reference": "0345f3b05f136801af8c339f9d16ef29e6b4df8a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/pint/zipball/0345f3b05f136801af8c339f9d16ef29e6b4df8a", + "reference": "0345f3b05f136801af8c339f9d16ef29e6b4df8a", + "shasum": "" + }, + "require": { + "ext-json": "*", + "ext-mbstring": "*", + "ext-tokenizer": "*", + "ext-xml": "*", + "php": "^8.2.0" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^3.82.2", + "illuminate/view": "^11.45.1", + "larastan/larastan": "^3.5.0", + "laravel-zero/framework": "^11.45.0", + "mockery/mockery": "^1.6.12", + "nunomaduro/termwind": "^2.3.1", + "pestphp/pest": "^2.36.0" + }, + "bin": [ + "builds/pint" + ], + "type": "project", + "autoload": { + "files": [ + "overrides/Runner/Parallel/ProcessFactory.php" + ], + "psr-4": { + "App\\": "app/", + "Database\\Seeders\\": "database/seeders/", + "Database\\Factories\\": "database/factories/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nuno Maduro", + "email": "enunomaduro@gmail.com" + } + ], + "description": "An opinionated code formatter for PHP.", + "homepage": "https://laravel.com", + "keywords": [ + "format", + "formatter", + "lint", + "linter", + "php" + ], + "support": { + "issues": "https://github.com/laravel/pint/issues", + "source": "https://github.com/laravel/pint" + }, + "time": "2025-07-10T18:09:32+00:00" + }, + { + "name": "laravel/sail", + "version": "v1.43.1", + "source": { + "type": "git", + "url": "https://github.com/laravel/sail.git", + "reference": "3e7d899232a8c5e3ea4fc6dee7525ad583887e72" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/sail/zipball/3e7d899232a8c5e3ea4fc6dee7525ad583887e72", + "reference": "3e7d899232a8c5e3ea4fc6dee7525ad583887e72", + "shasum": "" + }, + "require": { + "illuminate/console": "^9.52.16|^10.0|^11.0|^12.0", + "illuminate/contracts": "^9.52.16|^10.0|^11.0|^12.0", + "illuminate/support": "^9.52.16|^10.0|^11.0|^12.0", + "php": "^8.0", + "symfony/console": "^6.0|^7.0", + "symfony/yaml": "^6.0|^7.0" + }, + "require-dev": { + "orchestra/testbench": "^7.0|^8.0|^9.0|^10.0", + "phpstan/phpstan": "^1.10" + }, + "bin": [ + "bin/sail" + ], + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Laravel\\Sail\\SailServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Laravel\\Sail\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "Docker files for running a basic Laravel application.", + "keywords": [ + "docker", + "laravel" + ], + "support": { + "issues": "https://github.com/laravel/sail/issues", + "source": "https://github.com/laravel/sail" + }, + "time": "2025-05-19T13:19:21+00:00" + }, + { + "name": "mockery/mockery", + "version": "1.6.12", + "source": { + "type": "git", + "url": "https://github.com/mockery/mockery.git", + "reference": "1f4efdd7d3beafe9807b08156dfcb176d18f1699" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/mockery/mockery/zipball/1f4efdd7d3beafe9807b08156dfcb176d18f1699", + "reference": "1f4efdd7d3beafe9807b08156dfcb176d18f1699", + "shasum": "" + }, + "require": { + "hamcrest/hamcrest-php": "^2.0.1", + "lib-pcre": ">=7.0", + "php": ">=7.3" + }, + "conflict": { + "phpunit/phpunit": "<8.0" + }, + "require-dev": { + "phpunit/phpunit": "^8.5 || ^9.6.17", + "symplify/easy-coding-standard": "^12.1.14" + }, + "type": "library", + "autoload": { + "files": [ + "library/helpers.php", + "library/Mockery.php" + ], + "psr-4": { + "Mockery\\": "library/Mockery" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Pádraic Brady", + "email": "padraic.brady@gmail.com", + "homepage": "https://github.com/padraic", + "role": "Author" + }, + { + "name": "Dave Marshall", + "email": "dave.marshall@atstsolutions.co.uk", + "homepage": "https://davedevelopment.co.uk", + "role": "Developer" + }, + { + "name": "Nathanael Esayeas", + "email": "nathanael.esayeas@protonmail.com", + "homepage": "https://github.com/ghostwriter", + "role": "Lead Developer" + } + ], + "description": "Mockery is a simple yet flexible PHP mock object framework", + "homepage": "https://github.com/mockery/mockery", + "keywords": [ + "BDD", + "TDD", + "library", + "mock", + "mock objects", + "mockery", + "stub", + "test", + "test double", + "testing" + ], + "support": { + "docs": "https://docs.mockery.io/", + "issues": "https://github.com/mockery/mockery/issues", + "rss": "https://github.com/mockery/mockery/releases.atom", + "security": "https://github.com/mockery/mockery/security/advisories", + "source": "https://github.com/mockery/mockery" + }, + "time": "2024-05-16T03:13:13+00:00" + }, + { + "name": "myclabs/deep-copy", + "version": "1.13.3", + "source": { + "type": "git", + "url": "https://github.com/myclabs/DeepCopy.git", + "reference": "faed855a7b5f4d4637717c2b3863e277116beb36" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/faed855a7b5f4d4637717c2b3863e277116beb36", + "reference": "faed855a7b5f4d4637717c2b3863e277116beb36", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0" + }, + "conflict": { + "doctrine/collections": "<1.6.8", + "doctrine/common": "<2.13.3 || >=3 <3.2.2" + }, + "require-dev": { + "doctrine/collections": "^1.6.8", + "doctrine/common": "^2.13.3 || ^3.2.2", + "phpspec/prophecy": "^1.10", + "phpunit/phpunit": "^7.5.20 || ^8.5.23 || ^9.5.13" + }, + "type": "library", + "autoload": { + "files": [ + "src/DeepCopy/deep_copy.php" + ], + "psr-4": { + "DeepCopy\\": "src/DeepCopy/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Create deep copies (clones) of your objects", + "keywords": [ + "clone", + "copy", + "duplicate", + "object", + "object graph" + ], + "support": { + "issues": "https://github.com/myclabs/DeepCopy/issues", + "source": "https://github.com/myclabs/DeepCopy/tree/1.13.3" + }, + "funding": [ + { + "url": "https://tidelift.com/funding/github/packagist/myclabs/deep-copy", + "type": "tidelift" + } + ], + "time": "2025-07-05T12:25:42+00:00" + }, + { + "name": "nunomaduro/collision", + "version": "v8.8.2", + "source": { + "type": "git", + "url": "https://github.com/nunomaduro/collision.git", + "reference": "60207965f9b7b7a4ce15a0f75d57f9dadb105bdb" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nunomaduro/collision/zipball/60207965f9b7b7a4ce15a0f75d57f9dadb105bdb", + "reference": "60207965f9b7b7a4ce15a0f75d57f9dadb105bdb", + "shasum": "" + }, + "require": { + "filp/whoops": "^2.18.1", + "nunomaduro/termwind": "^2.3.1", + "php": "^8.2.0", + "symfony/console": "^7.3.0" + }, + "conflict": { + "laravel/framework": "<11.44.2 || >=13.0.0", + "phpunit/phpunit": "<11.5.15 || >=13.0.0" + }, + "require-dev": { + "brianium/paratest": "^7.8.3", + "larastan/larastan": "^3.4.2", + "laravel/framework": "^11.44.2 || ^12.18", + "laravel/pint": "^1.22.1", + "laravel/sail": "^1.43.1", + "laravel/sanctum": "^4.1.1", + "laravel/tinker": "^2.10.1", + "orchestra/testbench-core": "^9.12.0 || ^10.4", + "pestphp/pest": "^3.8.2", + "sebastian/environment": "^7.2.1 || ^8.0" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "NunoMaduro\\Collision\\Adapters\\Laravel\\CollisionServiceProvider" + ] + }, + "branch-alias": { + "dev-8.x": "8.x-dev" + } + }, + "autoload": { + "files": [ + "./src/Adapters/Phpunit/Autoload.php" + ], + "psr-4": { + "NunoMaduro\\Collision\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nuno Maduro", + "email": "enunomaduro@gmail.com" + } + ], + "description": "Cli error handling for console/command-line PHP applications.", + "keywords": [ + "artisan", + "cli", + "command-line", + "console", + "dev", + "error", + "handling", + "laravel", + "laravel-zero", + "php", + "symfony" + ], + "support": { + "issues": "https://github.com/nunomaduro/collision/issues", + "source": "https://github.com/nunomaduro/collision" + }, + "funding": [ + { + "url": "https://www.paypal.com/paypalme/enunomaduro", + "type": "custom" + }, + { + "url": "https://github.com/nunomaduro", + "type": "github" + }, + { + "url": "https://www.patreon.com/nunomaduro", + "type": "patreon" + } + ], + "time": "2025-06-25T02:12:12+00:00" + }, + { + "name": "phar-io/manifest", + "version": "2.0.4", + "source": { + "type": "git", + "url": "https://github.com/phar-io/manifest.git", + "reference": "54750ef60c58e43759730615a392c31c80e23176" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phar-io/manifest/zipball/54750ef60c58e43759730615a392c31c80e23176", + "reference": "54750ef60c58e43759730615a392c31c80e23176", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-libxml": "*", + "ext-phar": "*", + "ext-xmlwriter": "*", + "phar-io/version": "^3.0.1", + "php": "^7.2 || ^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + }, + { + "name": "Sebastian Heuer", + "email": "sebastian@phpeople.de", + "role": "Developer" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "Developer" + } + ], + "description": "Component for reading phar.io manifest information from a PHP Archive (PHAR)", + "support": { + "issues": "https://github.com/phar-io/manifest/issues", + "source": "https://github.com/phar-io/manifest/tree/2.0.4" + }, + "funding": [ + { + "url": "https://github.com/theseer", + "type": "github" + } + ], + "time": "2024-03-03T12:33:53+00:00" + }, + { + "name": "phar-io/version", + "version": "3.2.1", + "source": { + "type": "git", + "url": "https://github.com/phar-io/version.git", + "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phar-io/version/zipball/4f7fd7836c6f332bb2933569e566a0d6c4cbed74", + "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + }, + { + "name": "Sebastian Heuer", + "email": "sebastian@phpeople.de", + "role": "Developer" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "Developer" + } + ], + "description": "Library for handling version information and constraints", + "support": { + "issues": "https://github.com/phar-io/version/issues", + "source": "https://github.com/phar-io/version/tree/3.2.1" + }, + "time": "2022-02-21T01:04:05+00:00" + }, + { + "name": "php-debugbar/php-debugbar", + "version": "v2.1.6", + "source": { + "type": "git", + "url": "https://github.com/php-debugbar/php-debugbar.git", + "reference": "16fa68da5617220594aa5e33fa9de415f94784a0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-debugbar/php-debugbar/zipball/16fa68da5617220594aa5e33fa9de415f94784a0", + "reference": "16fa68da5617220594aa5e33fa9de415f94784a0", + "shasum": "" + }, + "require": { + "php": "^8", + "psr/log": "^1|^2|^3", + "symfony/var-dumper": "^4|^5|^6|^7" + }, + "require-dev": { + "dbrekelmans/bdi": "^1", + "phpunit/phpunit": "^8|^9", + "symfony/panther": "^1|^2.1", + "twig/twig": "^1.38|^2.7|^3.0" + }, + "suggest": { + "kriswallsmith/assetic": "The best way to manage assets", + "monolog/monolog": "Log using Monolog", + "predis/predis": "Redis storage" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0-dev" + } + }, + "autoload": { + "psr-4": { + "DebugBar\\": "src/DebugBar/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Maxime Bouroumeau-Fuseau", + "email": "maxime.bouroumeau@gmail.com", + "homepage": "http://maximebf.com" + }, + { + "name": "Barry vd. Heuvel", + "email": "barryvdh@gmail.com" + } + ], + "description": "Debug bar in the browser for php application", + "homepage": "https://github.com/php-debugbar/php-debugbar", + "keywords": [ + "debug", + "debug bar", + "debugbar", + "dev" + ], + "support": { + "issues": "https://github.com/php-debugbar/php-debugbar/issues", + "source": "https://github.com/php-debugbar/php-debugbar/tree/v2.1.6" + }, + "time": "2025-02-21T17:47:03+00:00" + }, + { + "name": "phpstan/phpdoc-parser", + "version": "2.2.0", + "source": { + "type": "git", + "url": "https://github.com/phpstan/phpdoc-parser.git", + "reference": "b9e61a61e39e02dd90944e9115241c7f7e76bfd8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/b9e61a61e39e02dd90944e9115241c7f7e76bfd8", + "reference": "b9e61a61e39e02dd90944e9115241c7f7e76bfd8", + "shasum": "" + }, + "require": { + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "doctrine/annotations": "^2.0", + "nikic/php-parser": "^5.3.0", + "php-parallel-lint/php-parallel-lint": "^1.2", + "phpstan/extension-installer": "^1.0", + "phpstan/phpstan": "^2.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpstan/phpstan-strict-rules": "^2.0", + "phpunit/phpunit": "^9.6", + "symfony/process": "^5.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "PHPStan\\PhpDocParser\\": [ + "src/" + ] + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "PHPDoc parser with support for nullable, intersection and generic types", + "support": { + "issues": "https://github.com/phpstan/phpdoc-parser/issues", + "source": "https://github.com/phpstan/phpdoc-parser/tree/2.2.0" + }, + "time": "2025-07-13T07:04:09+00:00" + }, + { + "name": "phpstan/phpstan", + "version": "2.1.18", + "source": { + "type": "git", + "url": "https://github.com/phpstan/phpstan.git", + "reference": "ee1f390b7a70cdf74a2b737e554f68afea885db7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/ee1f390b7a70cdf74a2b737e554f68afea885db7", + "reference": "ee1f390b7a70cdf74a2b737e554f68afea885db7", + "shasum": "" + }, + "require": { + "php": "^7.4|^8.0" + }, + "conflict": { + "phpstan/phpstan-shim": "*" + }, + "bin": [ + "phpstan", + "phpstan.phar" + ], + "type": "library", + "autoload": { + "files": [ + "bootstrap.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "PHPStan - PHP Static Analysis Tool", + "keywords": [ + "dev", + "static analysis" + ], + "support": { + "docs": "https://phpstan.org/user-guide/getting-started", + "forum": "https://github.com/phpstan/phpstan/discussions", + "issues": "https://github.com/phpstan/phpstan/issues", + "security": "https://github.com/phpstan/phpstan/security/policy", + "source": "https://github.com/phpstan/phpstan-src" + }, + "funding": [ + { + "url": "https://github.com/ondrejmirtes", + "type": "github" + }, + { + "url": "https://github.com/phpstan", + "type": "github" + } + ], + "time": "2025-07-17T17:22:31+00:00" + }, + { + "name": "phpunit/php-code-coverage", + "version": "11.0.10", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-code-coverage.git", + "reference": "1a800a7446add2d79cc6b3c01c45381810367d76" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/1a800a7446add2d79cc6b3c01c45381810367d76", + "reference": "1a800a7446add2d79cc6b3c01c45381810367d76", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-libxml": "*", + "ext-xmlwriter": "*", + "nikic/php-parser": "^5.4.0", + "php": ">=8.2", + "phpunit/php-file-iterator": "^5.1.0", + "phpunit/php-text-template": "^4.0.1", + "sebastian/code-unit-reverse-lookup": "^4.0.1", + "sebastian/complexity": "^4.0.1", + "sebastian/environment": "^7.2.0", + "sebastian/lines-of-code": "^3.0.1", + "sebastian/version": "^5.0.2", + "theseer/tokenizer": "^1.2.3" + }, + "require-dev": { + "phpunit/phpunit": "^11.5.2" + }, + "suggest": { + "ext-pcov": "PHP extension that provides line coverage", + "ext-xdebug": "PHP extension that provides line coverage as well as branch and path coverage" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "11.0.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library that provides collection, processing, and rendering functionality for PHP code coverage information.", + "homepage": "https://github.com/sebastianbergmann/php-code-coverage", + "keywords": [ + "coverage", + "testing", + "xunit" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues", + "security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy", + "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/show" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/php-code-coverage", + "type": "tidelift" + } + ], + "time": "2025-06-18T08:56:18+00:00" + }, + { + "name": "phpunit/php-file-iterator", + "version": "5.1.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-file-iterator.git", + "reference": "118cfaaa8bc5aef3287bf315b6060b1174754af6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/118cfaaa8bc5aef3287bf315b6060b1174754af6", + "reference": "118cfaaa8bc5aef3287bf315b6060b1174754af6", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "FilterIterator implementation that filters files based on a list of suffixes.", + "homepage": "https://github.com/sebastianbergmann/php-file-iterator/", + "keywords": [ + "filesystem", + "iterator" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-file-iterator/issues", + "security": "https://github.com/sebastianbergmann/php-file-iterator/security/policy", + "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/5.1.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-08-27T05:02:59+00:00" + }, + { + "name": "phpunit/php-invoker", + "version": "5.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-invoker.git", + "reference": "c1ca3814734c07492b3d4c5f794f4b0995333da2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-invoker/zipball/c1ca3814734c07492b3d4c5f794f4b0995333da2", + "reference": "c1ca3814734c07492b3d4c5f794f4b0995333da2", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "ext-pcntl": "*", + "phpunit/phpunit": "^11.0" + }, + "suggest": { + "ext-pcntl": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Invoke callables with a timeout", + "homepage": "https://github.com/sebastianbergmann/php-invoker/", + "keywords": [ + "process" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-invoker/issues", + "security": "https://github.com/sebastianbergmann/php-invoker/security/policy", + "source": "https://github.com/sebastianbergmann/php-invoker/tree/5.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T05:07:44+00:00" + }, + { + "name": "phpunit/php-text-template", + "version": "4.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-text-template.git", + "reference": "3e0404dc6b300e6bf56415467ebcb3fe4f33e964" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/3e0404dc6b300e6bf56415467ebcb3fe4f33e964", + "reference": "3e0404dc6b300e6bf56415467ebcb3fe4f33e964", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Simple template engine.", + "homepage": "https://github.com/sebastianbergmann/php-text-template/", + "keywords": [ + "template" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-text-template/issues", + "security": "https://github.com/sebastianbergmann/php-text-template/security/policy", + "source": "https://github.com/sebastianbergmann/php-text-template/tree/4.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T05:08:43+00:00" + }, + { + "name": "phpunit/php-timer", + "version": "7.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-timer.git", + "reference": "3b415def83fbcb41f991d9ebf16ae4ad8b7837b3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/3b415def83fbcb41f991d9ebf16ae4ad8b7837b3", + "reference": "3b415def83fbcb41f991d9ebf16ae4ad8b7837b3", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Utility class for timing", + "homepage": "https://github.com/sebastianbergmann/php-timer/", + "keywords": [ + "timer" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-timer/issues", + "security": "https://github.com/sebastianbergmann/php-timer/security/policy", + "source": "https://github.com/sebastianbergmann/php-timer/tree/7.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T05:09:35+00:00" + }, + { + "name": "phpunit/phpunit", + "version": "11.5.27", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/phpunit.git", + "reference": "446d43867314781df7e9adf79c3ec7464956fd8f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/446d43867314781df7e9adf79c3ec7464956fd8f", + "reference": "446d43867314781df7e9adf79c3ec7464956fd8f", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-json": "*", + "ext-libxml": "*", + "ext-mbstring": "*", + "ext-xml": "*", + "ext-xmlwriter": "*", + "myclabs/deep-copy": "^1.13.3", + "phar-io/manifest": "^2.0.4", + "phar-io/version": "^3.2.1", + "php": ">=8.2", + "phpunit/php-code-coverage": "^11.0.10", + "phpunit/php-file-iterator": "^5.1.0", + "phpunit/php-invoker": "^5.0.1", + "phpunit/php-text-template": "^4.0.1", + "phpunit/php-timer": "^7.0.1", + "sebastian/cli-parser": "^3.0.2", + "sebastian/code-unit": "^3.0.3", + "sebastian/comparator": "^6.3.1", + "sebastian/diff": "^6.0.2", + "sebastian/environment": "^7.2.1", + "sebastian/exporter": "^6.3.0", + "sebastian/global-state": "^7.0.2", + "sebastian/object-enumerator": "^6.0.1", + "sebastian/type": "^5.1.2", + "sebastian/version": "^5.0.2", + "staabm/side-effects-detector": "^1.0.5" + }, + "suggest": { + "ext-soap": "To be able to generate mocks based on WSDL files" + }, + "bin": [ + "phpunit" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "11.5-dev" + } + }, + "autoload": { + "files": [ + "src/Framework/Assert/Functions.php" + ], + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "The PHP Unit Testing framework.", + "homepage": "https://phpunit.de/", + "keywords": [ + "phpunit", + "testing", + "xunit" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/phpunit/issues", + "security": "https://github.com/sebastianbergmann/phpunit/security/policy", + "source": "https://github.com/sebastianbergmann/phpunit/tree/11.5.27" + }, + "funding": [ + { + "url": "https://phpunit.de/sponsors.html", + "type": "custom" + }, + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/phpunit", + "type": "tidelift" + } + ], + "time": "2025-07-11T04:10:06+00:00" + }, + { + "name": "sebastian/cli-parser", + "version": "3.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/cli-parser.git", + "reference": "15c5dd40dc4f38794d383bb95465193f5e0ae180" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/15c5dd40dc4f38794d383bb95465193f5e0ae180", + "reference": "15c5dd40dc4f38794d383bb95465193f5e0ae180", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for parsing CLI options", + "homepage": "https://github.com/sebastianbergmann/cli-parser", + "support": { + "issues": "https://github.com/sebastianbergmann/cli-parser/issues", + "security": "https://github.com/sebastianbergmann/cli-parser/security/policy", + "source": "https://github.com/sebastianbergmann/cli-parser/tree/3.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T04:41:36+00:00" + }, + { + "name": "sebastian/code-unit", + "version": "3.0.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/code-unit.git", + "reference": "54391c61e4af8078e5b276ab082b6d3c54c9ad64" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/code-unit/zipball/54391c61e4af8078e5b276ab082b6d3c54c9ad64", + "reference": "54391c61e4af8078e5b276ab082b6d3c54c9ad64", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.5" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Collection of value objects that represent the PHP code units", + "homepage": "https://github.com/sebastianbergmann/code-unit", + "support": { + "issues": "https://github.com/sebastianbergmann/code-unit/issues", + "security": "https://github.com/sebastianbergmann/code-unit/security/policy", + "source": "https://github.com/sebastianbergmann/code-unit/tree/3.0.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2025-03-19T07:56:08+00:00" + }, + { + "name": "sebastian/code-unit-reverse-lookup", + "version": "4.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/code-unit-reverse-lookup.git", + "reference": "183a9b2632194febd219bb9246eee421dad8d45e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/code-unit-reverse-lookup/zipball/183a9b2632194febd219bb9246eee421dad8d45e", + "reference": "183a9b2632194febd219bb9246eee421dad8d45e", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Looks up which function or method a line of code belongs to", + "homepage": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/", + "support": { + "issues": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/issues", + "security": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/security/policy", + "source": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/tree/4.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T04:45:54+00:00" + }, + { + "name": "sebastian/comparator", + "version": "6.3.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/comparator.git", + "reference": "24b8fbc2c8e201bb1308e7b05148d6ab393b6959" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/24b8fbc2c8e201bb1308e7b05148d6ab393b6959", + "reference": "24b8fbc2c8e201bb1308e7b05148d6ab393b6959", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-mbstring": "*", + "php": ">=8.2", + "sebastian/diff": "^6.0", + "sebastian/exporter": "^6.0" + }, + "require-dev": { + "phpunit/phpunit": "^11.4" + }, + "suggest": { + "ext-bcmath": "For comparing BcMath\\Number objects" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.3-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Volker Dusch", + "email": "github@wallbash.com" + }, + { + "name": "Bernhard Schussek", + "email": "bschussek@2bepublished.at" + } + ], + "description": "Provides the functionality to compare PHP values for equality", + "homepage": "https://github.com/sebastianbergmann/comparator", + "keywords": [ + "comparator", + "compare", + "equality" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/comparator/issues", + "security": "https://github.com/sebastianbergmann/comparator/security/policy", + "source": "https://github.com/sebastianbergmann/comparator/tree/6.3.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2025-03-07T06:57:01+00:00" + }, + { + "name": "sebastian/complexity", + "version": "4.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/complexity.git", + "reference": "ee41d384ab1906c68852636b6de493846e13e5a0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/complexity/zipball/ee41d384ab1906c68852636b6de493846e13e5a0", + "reference": "ee41d384ab1906c68852636b6de493846e13e5a0", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^5.0", + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for calculating the complexity of PHP code units", + "homepage": "https://github.com/sebastianbergmann/complexity", + "support": { + "issues": "https://github.com/sebastianbergmann/complexity/issues", + "security": "https://github.com/sebastianbergmann/complexity/security/policy", + "source": "https://github.com/sebastianbergmann/complexity/tree/4.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T04:49:50+00:00" + }, + { + "name": "sebastian/diff", + "version": "6.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/diff.git", + "reference": "b4ccd857127db5d41a5b676f24b51371d76d8544" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/b4ccd857127db5d41a5b676f24b51371d76d8544", + "reference": "b4ccd857127db5d41a5b676f24b51371d76d8544", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0", + "symfony/process": "^4.2 || ^5" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Kore Nordmann", + "email": "mail@kore-nordmann.de" + } + ], + "description": "Diff implementation", + "homepage": "https://github.com/sebastianbergmann/diff", + "keywords": [ + "diff", + "udiff", + "unidiff", + "unified diff" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/diff/issues", + "security": "https://github.com/sebastianbergmann/diff/security/policy", + "source": "https://github.com/sebastianbergmann/diff/tree/6.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T04:53:05+00:00" + }, + { + "name": "sebastian/environment", + "version": "7.2.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/environment.git", + "reference": "a5c75038693ad2e8d4b6c15ba2403532647830c4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/a5c75038693ad2e8d4b6c15ba2403532647830c4", + "reference": "a5c75038693ad2e8d4b6c15ba2403532647830c4", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.3" + }, + "suggest": { + "ext-posix": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.2-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Provides functionality to handle HHVM/PHP environments", + "homepage": "https://github.com/sebastianbergmann/environment", + "keywords": [ + "Xdebug", + "environment", + "hhvm" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/environment/issues", + "security": "https://github.com/sebastianbergmann/environment/security/policy", + "source": "https://github.com/sebastianbergmann/environment/tree/7.2.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/environment", + "type": "tidelift" + } + ], + "time": "2025-05-21T11:55:47+00:00" + }, + { + "name": "sebastian/exporter", + "version": "6.3.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/exporter.git", + "reference": "3473f61172093b2da7de1fb5782e1f24cc036dc3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/3473f61172093b2da7de1fb5782e1f24cc036dc3", + "reference": "3473f61172093b2da7de1fb5782e1f24cc036dc3", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "php": ">=8.2", + "sebastian/recursion-context": "^6.0" + }, + "require-dev": { + "phpunit/phpunit": "^11.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.1-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Volker Dusch", + "email": "github@wallbash.com" + }, + { + "name": "Adam Harvey", + "email": "aharvey@php.net" + }, + { + "name": "Bernhard Schussek", + "email": "bschussek@gmail.com" + } + ], + "description": "Provides the functionality to export PHP variables for visualization", + "homepage": "https://www.github.com/sebastianbergmann/exporter", + "keywords": [ + "export", + "exporter" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/exporter/issues", + "security": "https://github.com/sebastianbergmann/exporter/security/policy", + "source": "https://github.com/sebastianbergmann/exporter/tree/6.3.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-12-05T09:17:50+00:00" + }, + { + "name": "sebastian/global-state", + "version": "7.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/global-state.git", + "reference": "3be331570a721f9a4b5917f4209773de17f747d7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/3be331570a721f9a4b5917f4209773de17f747d7", + "reference": "3be331570a721f9a4b5917f4209773de17f747d7", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "sebastian/object-reflector": "^4.0", + "sebastian/recursion-context": "^6.0" + }, + "require-dev": { + "ext-dom": "*", + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Snapshotting of global state", + "homepage": "https://www.github.com/sebastianbergmann/global-state", + "keywords": [ + "global state" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/global-state/issues", + "security": "https://github.com/sebastianbergmann/global-state/security/policy", + "source": "https://github.com/sebastianbergmann/global-state/tree/7.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T04:57:36+00:00" + }, + { + "name": "sebastian/lines-of-code", + "version": "3.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/lines-of-code.git", + "reference": "d36ad0d782e5756913e42ad87cb2890f4ffe467a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/d36ad0d782e5756913e42ad87cb2890f4ffe467a", + "reference": "d36ad0d782e5756913e42ad87cb2890f4ffe467a", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^5.0", + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for counting the lines of code in PHP source code", + "homepage": "https://github.com/sebastianbergmann/lines-of-code", + "support": { + "issues": "https://github.com/sebastianbergmann/lines-of-code/issues", + "security": "https://github.com/sebastianbergmann/lines-of-code/security/policy", + "source": "https://github.com/sebastianbergmann/lines-of-code/tree/3.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T04:58:38+00:00" + }, + { + "name": "sebastian/object-enumerator", + "version": "6.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/object-enumerator.git", + "reference": "f5b498e631a74204185071eb41f33f38d64608aa" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/f5b498e631a74204185071eb41f33f38d64608aa", + "reference": "f5b498e631a74204185071eb41f33f38d64608aa", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "sebastian/object-reflector": "^4.0", + "sebastian/recursion-context": "^6.0" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Traverses array structures and object graphs to enumerate all referenced objects", + "homepage": "https://github.com/sebastianbergmann/object-enumerator/", + "support": { + "issues": "https://github.com/sebastianbergmann/object-enumerator/issues", + "security": "https://github.com/sebastianbergmann/object-enumerator/security/policy", + "source": "https://github.com/sebastianbergmann/object-enumerator/tree/6.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T05:00:13+00:00" + }, + { + "name": "sebastian/object-reflector", + "version": "4.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/object-reflector.git", + "reference": "6e1a43b411b2ad34146dee7524cb13a068bb35f9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/6e1a43b411b2ad34146dee7524cb13a068bb35f9", + "reference": "6e1a43b411b2ad34146dee7524cb13a068bb35f9", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Allows reflection of object attributes, including inherited and non-public ones", + "homepage": "https://github.com/sebastianbergmann/object-reflector/", + "support": { + "issues": "https://github.com/sebastianbergmann/object-reflector/issues", + "security": "https://github.com/sebastianbergmann/object-reflector/security/policy", + "source": "https://github.com/sebastianbergmann/object-reflector/tree/4.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T05:01:32+00:00" + }, + { + "name": "sebastian/recursion-context", + "version": "6.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/recursion-context.git", + "reference": "694d156164372abbd149a4b85ccda2e4670c0e16" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/694d156164372abbd149a4b85ccda2e4670c0e16", + "reference": "694d156164372abbd149a4b85ccda2e4670c0e16", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Adam Harvey", + "email": "aharvey@php.net" + } + ], + "description": "Provides functionality to recursively process PHP variables", + "homepage": "https://github.com/sebastianbergmann/recursion-context", + "support": { + "issues": "https://github.com/sebastianbergmann/recursion-context/issues", + "security": "https://github.com/sebastianbergmann/recursion-context/security/policy", + "source": "https://github.com/sebastianbergmann/recursion-context/tree/6.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T05:10:34+00:00" + }, + { + "name": "sebastian/type", + "version": "5.1.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/type.git", + "reference": "a8a7e30534b0eb0c77cd9d07e82de1a114389f5e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/a8a7e30534b0eb0c77cd9d07e82de1a114389f5e", + "reference": "a8a7e30534b0eb0c77cd9d07e82de1a114389f5e", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.1-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Collection of value objects that represent the types of the PHP type system", + "homepage": "https://github.com/sebastianbergmann/type", + "support": { + "issues": "https://github.com/sebastianbergmann/type/issues", + "security": "https://github.com/sebastianbergmann/type/security/policy", + "source": "https://github.com/sebastianbergmann/type/tree/5.1.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2025-03-18T13:35:50+00:00" + }, + { + "name": "sebastian/version", + "version": "5.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/version.git", + "reference": "c687e3387b99f5b03b6caa64c74b63e2936ff874" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/c687e3387b99f5b03b6caa64c74b63e2936ff874", + "reference": "c687e3387b99f5b03b6caa64c74b63e2936ff874", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library that helps with managing the version number of Git-hosted PHP projects", + "homepage": "https://github.com/sebastianbergmann/version", + "support": { + "issues": "https://github.com/sebastianbergmann/version/issues", + "security": "https://github.com/sebastianbergmann/version/security/policy", + "source": "https://github.com/sebastianbergmann/version/tree/5.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-10-09T05:16:32+00:00" + }, + { + "name": "spatie/backtrace", + "version": "1.7.4", + "source": { + "type": "git", + "url": "https://github.com/spatie/backtrace.git", + "reference": "cd37a49fce7137359ac30ecc44ef3e16404cccbe" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/spatie/backtrace/zipball/cd37a49fce7137359ac30ecc44ef3e16404cccbe", + "reference": "cd37a49fce7137359ac30ecc44ef3e16404cccbe", + "shasum": "" + }, + "require": { + "php": "^7.3 || ^8.0" + }, + "require-dev": { + "ext-json": "*", + "laravel/serializable-closure": "^1.3 || ^2.0", + "phpunit/phpunit": "^9.3 || ^11.4.3", + "spatie/phpunit-snapshot-assertions": "^4.2 || ^5.1.6", + "symfony/var-dumper": "^5.1 || ^6.0 || ^7.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Spatie\\Backtrace\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Freek Van de Herten", + "email": "freek@spatie.be", + "homepage": "https://spatie.be", + "role": "Developer" + } + ], + "description": "A better backtrace", + "homepage": "https://github.com/spatie/backtrace", + "keywords": [ + "Backtrace", + "spatie" + ], + "support": { + "source": "https://github.com/spatie/backtrace/tree/1.7.4" + }, + "funding": [ + { + "url": "https://github.com/sponsors/spatie", + "type": "github" + }, + { + "url": "https://spatie.be/open-source/support-us", + "type": "other" + } + ], + "time": "2025-05-08T15:41:09+00:00" + }, + { + "name": "spatie/error-solutions", + "version": "1.1.3", + "source": { + "type": "git", + "url": "https://github.com/spatie/error-solutions.git", + "reference": "e495d7178ca524f2dd0fe6a1d99a1e608e1c9936" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/spatie/error-solutions/zipball/e495d7178ca524f2dd0fe6a1d99a1e608e1c9936", + "reference": "e495d7178ca524f2dd0fe6a1d99a1e608e1c9936", + "shasum": "" + }, + "require": { + "php": "^8.0" + }, + "require-dev": { + "illuminate/broadcasting": "^10.0|^11.0|^12.0", + "illuminate/cache": "^10.0|^11.0|^12.0", + "illuminate/support": "^10.0|^11.0|^12.0", + "livewire/livewire": "^2.11|^3.5.20", + "openai-php/client": "^0.10.1", + "orchestra/testbench": "8.22.3|^9.0|^10.0", + "pestphp/pest": "^2.20|^3.0", + "phpstan/phpstan": "^2.1", + "psr/simple-cache": "^3.0", + "psr/simple-cache-implementation": "^3.0", + "spatie/ray": "^1.28", + "symfony/cache": "^5.4|^6.0|^7.0", + "symfony/process": "^5.4|^6.0|^7.0", + "vlucas/phpdotenv": "^5.5" + }, + "suggest": { + "openai-php/client": "Require get solutions from OpenAI", + "simple-cache-implementation": "To cache solutions from OpenAI" + }, + "type": "library", + "autoload": { + "psr-4": { + "Spatie\\Ignition\\": "legacy/ignition", + "Spatie\\ErrorSolutions\\": "src", + "Spatie\\LaravelIgnition\\": "legacy/laravel-ignition" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ruben Van Assche", + "email": "ruben@spatie.be", + "role": "Developer" + } + ], + "description": "This is my package error-solutions", + "homepage": "https://github.com/spatie/error-solutions", + "keywords": [ + "error-solutions", + "spatie" + ], + "support": { + "issues": "https://github.com/spatie/error-solutions/issues", + "source": "https://github.com/spatie/error-solutions/tree/1.1.3" + }, + "funding": [ + { + "url": "https://github.com/Spatie", + "type": "github" + } + ], + "time": "2025-02-14T12:29:50+00:00" + }, + { + "name": "spatie/flare-client-php", + "version": "1.10.1", + "source": { + "type": "git", + "url": "https://github.com/spatie/flare-client-php.git", + "reference": "bf1716eb98bd689451b071548ae9e70738dce62f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/spatie/flare-client-php/zipball/bf1716eb98bd689451b071548ae9e70738dce62f", + "reference": "bf1716eb98bd689451b071548ae9e70738dce62f", + "shasum": "" + }, + "require": { + "illuminate/pipeline": "^8.0|^9.0|^10.0|^11.0|^12.0", + "php": "^8.0", + "spatie/backtrace": "^1.6.1", + "symfony/http-foundation": "^5.2|^6.0|^7.0", + "symfony/mime": "^5.2|^6.0|^7.0", + "symfony/process": "^5.2|^6.0|^7.0", + "symfony/var-dumper": "^5.2|^6.0|^7.0" + }, + "require-dev": { + "dms/phpunit-arraysubset-asserts": "^0.5.0", + "pestphp/pest": "^1.20|^2.0", + "phpstan/extension-installer": "^1.1", + "phpstan/phpstan-deprecation-rules": "^1.0", + "phpstan/phpstan-phpunit": "^1.0", + "spatie/pest-plugin-snapshots": "^1.0|^2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.3.x-dev" + } + }, + "autoload": { + "files": [ + "src/helpers.php" + ], + "psr-4": { + "Spatie\\FlareClient\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Send PHP errors to Flare", + "homepage": "https://github.com/spatie/flare-client-php", + "keywords": [ + "exception", + "flare", + "reporting", + "spatie" + ], + "support": { + "issues": "https://github.com/spatie/flare-client-php/issues", + "source": "https://github.com/spatie/flare-client-php/tree/1.10.1" + }, + "funding": [ + { + "url": "https://github.com/spatie", + "type": "github" + } + ], + "time": "2025-02-14T13:42:06+00:00" + }, + { + "name": "spatie/ignition", + "version": "1.15.1", + "source": { + "type": "git", + "url": "https://github.com/spatie/ignition.git", + "reference": "31f314153020aee5af3537e507fef892ffbf8c85" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/spatie/ignition/zipball/31f314153020aee5af3537e507fef892ffbf8c85", + "reference": "31f314153020aee5af3537e507fef892ffbf8c85", + "shasum": "" + }, + "require": { + "ext-json": "*", + "ext-mbstring": "*", + "php": "^8.0", + "spatie/error-solutions": "^1.0", + "spatie/flare-client-php": "^1.7", + "symfony/console": "^5.4|^6.0|^7.0", + "symfony/var-dumper": "^5.4|^6.0|^7.0" + }, + "require-dev": { + "illuminate/cache": "^9.52|^10.0|^11.0|^12.0", + "mockery/mockery": "^1.4", + "pestphp/pest": "^1.20|^2.0", + "phpstan/extension-installer": "^1.1", + "phpstan/phpstan-deprecation-rules": "^1.0", + "phpstan/phpstan-phpunit": "^1.0", + "psr/simple-cache-implementation": "*", + "symfony/cache": "^5.4|^6.0|^7.0", + "symfony/process": "^5.4|^6.0|^7.0", + "vlucas/phpdotenv": "^5.5" + }, + "suggest": { + "openai-php/client": "Require get solutions from OpenAI", + "simple-cache-implementation": "To cache solutions from OpenAI" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.5.x-dev" + } + }, + "autoload": { + "psr-4": { + "Spatie\\Ignition\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Spatie", + "email": "info@spatie.be", + "role": "Developer" + } + ], + "description": "A beautiful error page for PHP applications.", + "homepage": "https://flareapp.io/ignition", + "keywords": [ + "error", + "flare", + "laravel", + "page" + ], + "support": { + "docs": "https://flareapp.io/docs/ignition-for-laravel/introduction", + "forum": "https://twitter.com/flareappio", + "issues": "https://github.com/spatie/ignition/issues", + "source": "https://github.com/spatie/ignition" + }, + "funding": [ + { + "url": "https://github.com/spatie", + "type": "github" + } + ], + "time": "2025-02-21T14:31:39+00:00" + }, + { + "name": "spatie/laravel-ignition", + "version": "2.9.1", + "source": { + "type": "git", + "url": "https://github.com/spatie/laravel-ignition.git", + "reference": "1baee07216d6748ebd3a65ba97381b051838707a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/spatie/laravel-ignition/zipball/1baee07216d6748ebd3a65ba97381b051838707a", + "reference": "1baee07216d6748ebd3a65ba97381b051838707a", + "shasum": "" + }, + "require": { + "ext-curl": "*", + "ext-json": "*", + "ext-mbstring": "*", + "illuminate/support": "^10.0|^11.0|^12.0", + "php": "^8.1", + "spatie/ignition": "^1.15", + "symfony/console": "^6.2.3|^7.0", + "symfony/var-dumper": "^6.2.3|^7.0" + }, + "require-dev": { + "livewire/livewire": "^2.11|^3.3.5", + "mockery/mockery": "^1.5.1", + "openai-php/client": "^0.8.1|^0.10", + "orchestra/testbench": "8.22.3|^9.0|^10.0", + "pestphp/pest": "^2.34|^3.7", + "phpstan/extension-installer": "^1.3.1", + "phpstan/phpstan-deprecation-rules": "^1.1.1|^2.0", + "phpstan/phpstan-phpunit": "^1.3.16|^2.0", + "vlucas/phpdotenv": "^5.5" + }, + "suggest": { + "openai-php/client": "Require get solutions from OpenAI", + "psr/simple-cache-implementation": "Needed to cache solutions from OpenAI" + }, + "type": "library", + "extra": { + "laravel": { + "aliases": { + "Flare": "Spatie\\LaravelIgnition\\Facades\\Flare" + }, + "providers": [ + "Spatie\\LaravelIgnition\\IgnitionServiceProvider" + ] + } + }, + "autoload": { + "files": [ + "src/helpers.php" + ], + "psr-4": { + "Spatie\\LaravelIgnition\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Spatie", + "email": "info@spatie.be", + "role": "Developer" + } + ], + "description": "A beautiful error page for Laravel applications.", + "homepage": "https://flareapp.io/ignition", + "keywords": [ + "error", + "flare", + "laravel", + "page" + ], + "support": { + "docs": "https://flareapp.io/docs/ignition-for-laravel/introduction", + "forum": "https://twitter.com/flareappio", + "issues": "https://github.com/spatie/laravel-ignition/issues", + "source": "https://github.com/spatie/laravel-ignition" + }, + "funding": [ + { + "url": "https://github.com/spatie", + "type": "github" + } + ], + "time": "2025-02-20T13:13:55+00:00" + }, + { + "name": "staabm/side-effects-detector", + "version": "1.0.5", + "source": { + "type": "git", + "url": "https://github.com/staabm/side-effects-detector.git", + "reference": "d8334211a140ce329c13726d4a715adbddd0a163" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/staabm/side-effects-detector/zipball/d8334211a140ce329c13726d4a715adbddd0a163", + "reference": "d8334211a140ce329c13726d4a715adbddd0a163", + "shasum": "" + }, + "require": { + "ext-tokenizer": "*", + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "phpstan/extension-installer": "^1.4.3", + "phpstan/phpstan": "^1.12.6", + "phpunit/phpunit": "^9.6.21", + "symfony/var-dumper": "^5.4.43", + "tomasvotruba/type-coverage": "1.0.0", + "tomasvotruba/unused-public": "1.0.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "lib/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "A static analysis tool to detect side effects in PHP code", + "keywords": [ + "static analysis" + ], + "support": { + "issues": "https://github.com/staabm/side-effects-detector/issues", + "source": "https://github.com/staabm/side-effects-detector/tree/1.0.5" + }, + "funding": [ + { + "url": "https://github.com/staabm", + "type": "github" + } + ], + "time": "2024-10-20T05:08:20+00:00" + }, + { + "name": "symfony/yaml", + "version": "v7.3.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/yaml.git", + "reference": "0c3555045a46ab3cd4cc5a69d161225195230edb" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/yaml/zipball/0c3555045a46ab3cd4cc5a69d161225195230edb", + "reference": "0c3555045a46ab3cd4cc5a69d161225195230edb", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3.0", + "symfony/polyfill-ctype": "^1.8" + }, + "conflict": { + "symfony/console": "<6.4" + }, + "require-dev": { + "symfony/console": "^6.4|^7.0" + }, + "bin": [ + "Resources/bin/yaml-lint" + ], + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Yaml\\": "" + }, + "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": "Loads and dumps YAML files", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/yaml/tree/v7.3.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-06-03T06:57:57+00:00" + }, + { + "name": "theseer/tokenizer", + "version": "1.2.3", + "source": { + "type": "git", + "url": "https://github.com/theseer/tokenizer.git", + "reference": "737eda637ed5e28c3413cb1ebe8bb52cbf1ca7a2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/theseer/tokenizer/zipball/737eda637ed5e28c3413cb1ebe8bb52cbf1ca7a2", + "reference": "737eda637ed5e28c3413cb1ebe8bb52cbf1ca7a2", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-tokenizer": "*", + "ext-xmlwriter": "*", + "php": "^7.2 || ^8.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + } + ], + "description": "A small library for converting tokenized PHP source code into XML and potentially other formats", + "support": { + "issues": "https://github.com/theseer/tokenizer/issues", + "source": "https://github.com/theseer/tokenizer/tree/1.2.3" + }, + "funding": [ + { + "url": "https://github.com/theseer", + "type": "github" + } + ], + "time": "2024-03-03T12:36:25+00:00" + } + ], + "aliases": [], + "minimum-stability": "stable", + "stability-flags": { + "timacdonald/json-api": 10 + }, + "prefer-stable": true, + "prefer-lowest": false, + "platform": { + "php": "^8.2 || ^8.3", + "ext-intl": "*", + "ext-json": "*", + "ext-mbstring": "*", + "ext-pdo": "*", + "ext-pdo_mysql": "*", + "ext-zip": "*" + }, + "platform-dev": {}, + "plugin-api-version": "2.6.0" +} diff --git a/config/app.php b/config/app.php new file mode 100644 index 000000000..a290a68bc --- /dev/null +++ b/config/app.php @@ -0,0 +1,412 @@ + env('APP_NAME', 'Paymenter'), + + 'version' => '1.2.10+VBv3', + + 'commit' => '', + + 'telemetry_enabled' => env('TELEMETRY_ENABLED', true), + + /* + |-------------------------------------------------------------------------- + | Application Environment + |-------------------------------------------------------------------------- + | + | This value determines the "environment" your application is currently + | running in. This may determine how you prefer to configure various + | services the application utilizes. Set this in your ".env" file. + | + */ + + 'env' => env('APP_ENV', 'production'), + + /* + |-------------------------------------------------------------------------- + | Application Debug Mode + |-------------------------------------------------------------------------- + | + | When your application is in debug mode, detailed error messages with + | stack traces will be shown on every error that occurs within your + | application. If disabled, a simple generic error page is shown. + | + */ + + 'debug' => (bool) env('APP_DEBUG', false), + + /* + |-------------------------------------------------------------------------- + | Application URL + |-------------------------------------------------------------------------- + | + | This URL is used by the console to properly generate URLs when using + | the Artisan command line tool. You should set this to the root of + | the application so that it's available within Artisan commands. + | + */ + + 'url' => env('APP_URL', 'http://localhost'), + + /* + |-------------------------------------------------------------------------- + | Application Timezone + |-------------------------------------------------------------------------- + | + | Here you may specify the default timezone for your application, which + | will be used by the PHP date and date-time functions. The timezone + | is set to "UTC" by default as it is suitable for most use cases. + | + */ + + 'timezone' => env('APP_TIMEZONE', 'UTC'), + + /* + |-------------------------------------------------------------------------- + | Application Locale Configuration + |-------------------------------------------------------------------------- + | + | The application locale determines the default locale that will be used + | by Laravel's translation / localization methods. This option can be + | set to any locale for which you plan to have translation strings. + | + */ + + 'locale' => env('APP_LOCALE', 'en'), + + 'fallback_locale' => env('APP_FALLBACK_LOCALE', 'en'), + + 'available_locales' => [ + 'ar' => 'Arabic', + 'de' => 'German', + 'en' => 'English', + 'es' => 'Spanish', + 'fi' => 'Finnish', + 'fr' => 'French', + 'it' => 'Italian', + 'sv' => 'Swedish', + 'uk' => 'Ukrainian', + 'ko' => 'Korean', + 'lv' => 'Latvian', + 'nl' => 'Dutch', + 'no' => 'Norwegian', + 'pt' => 'Portuguese', + 'sr' => 'Serbian', + ], + + 'rtl_locales' => [ + 'ar', + ], + + 'faker_locale' => env('APP_FAKER_LOCALE', 'en_US'), + + /* + |-------------------------------------------------------------------------- + | Encryption Key + |-------------------------------------------------------------------------- + | + | This key is utilized by Laravel's encryption services and should be set + | to a random, 32 character string to ensure that all encrypted values + | are secure. You should do this prior to deploying the application. + | + */ + + 'cipher' => 'AES-256-CBC', + + 'key' => env('APP_KEY'), + + 'previous_keys' => [ + ...array_filter( + explode(',', env('APP_PREVIOUS_KEYS', '')) + ), + ], + + /* + |-------------------------------------------------------------------------- + | Maintenance Mode Driver + |-------------------------------------------------------------------------- + | + | These configuration options determine the driver used to determine and + | manage Laravel's "maintenance mode" status. The "cache" driver will + | allow maintenance mode to be controlled across multiple machines. + | + | Supported drivers: "file", "cache" + | + */ + + 'maintenance' => [ + 'driver' => env('APP_MAINTENANCE_DRIVER', 'file'), + 'store' => env('APP_MAINTENANCE_STORE', 'database'), + ], + + 'aliases' => Facade::defaultAliases()->merge([ + 'Redis' => Illuminate\Support\Facades\Redis::class, + 'Cart' => App\Classes\Cart::class, + ])->toArray(), + + 'countries' => [ + '' => 'Select a country', + 'AF' => 'Afghanistan', + 'AX' => 'Aland Islands', + 'AL' => 'Albania', + 'DZ' => 'Algeria', + 'AS' => 'American Samoa', + 'AD' => 'Andorra', + 'AO' => 'Angola', + 'AI' => 'Anguilla', + 'AQ' => 'Antarctica', + 'AG' => 'Antigua And Barbuda', + 'AR' => 'Argentina', + 'AM' => 'Armenia', + 'AW' => 'Aruba', + 'AU' => 'Australia', + 'AT' => 'Austria', + 'AZ' => 'Azerbaijan', + 'BS' => 'Bahamas', + 'BH' => 'Bahrain', + 'BD' => 'Bangladesh', + 'BB' => 'Barbados', + 'BY' => 'Belarus', + 'BE' => 'Belgium', + 'BZ' => 'Belize', + 'BJ' => 'Benin', + 'BM' => 'Bermuda', + 'BT' => 'Bhutan', + 'BO' => 'Bolivia', + 'BA' => 'Bosnia And Herzegovina', + 'BW' => 'Botswana', + 'BR' => 'Brazil', + 'IO' => 'British Indian Ocean Territory', + 'BN' => 'Brunei Darussalam', + 'BG' => 'Bulgaria', + 'BF' => 'Burkina Faso', + 'BI' => 'Burundi', + 'KH' => 'Cambodia', + 'CM' => 'Cameroon', + 'CA' => 'Canada', + 'IC' => 'Canary Islands', + 'CV' => 'Cape Verde', + 'KY' => 'Cayman Islands', + 'CF' => 'Central African Republic', + 'TD' => 'Chad', + 'CL' => 'Chile', + 'CN' => 'China', + 'CX' => 'Christmas Island', + 'CC' => 'Cocos (Keeling) Islands', + 'CO' => 'Colombia', + 'KM' => 'Comoros', + 'CG' => 'Congo', + 'CD' => 'Congo, Democratic Republic', + 'CK' => 'Cook Islands', + 'CR' => 'Costa Rica', + 'CI' => 'Cote D\'Ivoire', + 'HR' => 'Croatia', + 'CU' => 'Cuba', + 'CW' => 'Curacao', + 'CY' => 'Cyprus', + 'CZ' => 'Czech Republic', + 'DK' => 'Denmark', + 'DJ' => 'Djibouti', + 'DM' => 'Dominica', + 'DO' => 'Dominican Republic', + 'EC' => 'Ecuador', + 'EG' => 'Egypt', + 'SV' => 'El Salvador', + 'GQ' => 'Equatorial Guinea', + 'ER' => 'Eritrea', + 'EE' => 'Estonia', + 'ET' => 'Ethiopia', + 'FK' => 'Falkland Islands (Malvinas)', + 'FO' => 'Faroe Islands', + 'FJ' => 'Fiji', + 'FI' => 'Finland', + 'FR' => 'France', + 'GF' => 'French Guiana', + 'PF' => 'French Polynesia', + 'TF' => 'French Southern Territories', + 'GA' => 'Gabon', + 'GM' => 'Gambia', + 'GE' => 'Georgia', + 'DE' => 'Germany', + 'GH' => 'Ghana', + 'GI' => 'Gibraltar', + 'GR' => 'Greece', + 'GL' => 'Greenland', + 'GD' => 'Grenada', + 'GP' => 'Guadeloupe', + 'GU' => 'Guam', + 'GT' => 'Guatemala', + 'GG' => 'Guernsey', + 'GN' => 'Guinea', + 'GW' => 'Guinea-Bissau', + 'GY' => 'Guyana', + 'HT' => 'Haiti', + 'HM' => 'Heard Island & Mcdonald Islands', + 'VA' => 'Holy See (Vatican City State)', + 'HN' => 'Honduras', + 'HK' => 'Hong Kong', + 'HU' => 'Hungary', + 'IS' => 'Iceland', + 'IN' => 'India', + 'ID' => 'Indonesia', + 'IR' => 'Iran, Islamic Republic Of', + 'IQ' => 'Iraq', + 'IE' => 'Ireland', + 'IM' => 'Isle Of Man', + 'IL' => 'Israel', + 'IT' => 'Italy', + 'JM' => 'Jamaica', + 'JP' => 'Japan', + 'JE' => 'Jersey', + 'JO' => 'Jordan', + 'KZ' => 'Kazakhstan', + 'KE' => 'Kenya', + 'KI' => 'Kiribati', + 'KR' => 'Korea', + 'XK' => 'Kosovo', + 'KW' => 'Kuwait', + 'KG' => 'Kyrgyzstan', + 'LA' => 'Lao People\'s Democratic Republic', + 'LV' => 'Latvia', + 'LB' => 'Lebanon', + 'LS' => 'Lesotho', + 'LR' => 'Liberia', + 'LY' => 'Libyan Arab Jamahiriya', + 'LI' => 'Liechtenstein', + 'LT' => 'Lithuania', + 'LU' => 'Luxembourg', + 'MO' => 'Macao', + 'MK' => 'Macedonia', + 'MG' => 'Madagascar', + 'MW' => 'Malawi', + 'MY' => 'Malaysia', + 'MV' => 'Maldives', + 'ML' => 'Mali', + 'MT' => 'Malta', + 'MH' => 'Marshall Islands', + 'MQ' => 'Martinique', + 'MR' => 'Mauritania', + 'MU' => 'Mauritius', + 'YT' => 'Mayotte', + 'MX' => 'Mexico', + 'FM' => 'Micronesia, Federated States Of', + 'MD' => 'Moldova', + 'MC' => 'Monaco', + 'MN' => 'Mongolia', + 'ME' => 'Montenegro', + 'MS' => 'Montserrat', + 'MA' => 'Morocco', + 'MZ' => 'Mozambique', + 'MM' => 'Myanmar', + 'NA' => 'Namibia', + 'NR' => 'Nauru', + 'NP' => 'Nepal', + 'NL' => 'Netherlands', + 'AN' => 'Netherlands Antilles', + 'NC' => 'New Caledonia', + 'NZ' => 'New Zealand', + 'NI' => 'Nicaragua', + 'NE' => 'Niger', + 'NG' => 'Nigeria', + 'NU' => 'Niue', + 'NF' => 'Norfolk Island', + 'MP' => 'Northern Mariana Islands', + 'NO' => 'Norway', + 'OM' => 'Oman', + 'PK' => 'Pakistan', + 'PW' => 'Palau', + 'PS' => 'Palestine, State of', + 'PA' => 'Panama', + 'PG' => 'Papua New Guinea', + 'PY' => 'Paraguay', + 'PE' => 'Peru', + 'PH' => 'Philippines', + 'PN' => 'Pitcairn', + 'PL' => 'Poland', + 'PT' => 'Portugal', + 'PR' => 'Puerto Rico', + 'QA' => 'Qatar', + 'RE' => 'Reunion', + 'RO' => 'Romania', + 'RU' => 'Russian Federation', + 'RW' => 'Rwanda', + 'BL' => 'Saint Barthelemy', + 'SH' => 'Saint Helena', + 'KN' => 'Saint Kitts And Nevis', + 'LC' => 'Saint Lucia', + 'MF' => 'Saint Martin', + 'PM' => 'Saint Pierre And Miquelon', + 'VC' => 'Saint Vincent And Grenadines', + 'WS' => 'Samoa', + 'SM' => 'San Marino', + 'ST' => 'Sao Tome And Principe', + 'SA' => 'Saudi Arabia', + 'SN' => 'Senegal', + 'RS' => 'Serbia', + 'SC' => 'Seychelles', + 'SL' => 'Sierra Leone', + 'SG' => 'Singapore', + 'SK' => 'Slovakia', + 'SI' => 'Slovenia', + 'SB' => 'Solomon Islands', + 'SO' => 'Somalia', + 'ZA' => 'South Africa', + 'GS' => 'South Georgia And Sandwich Isl.', + 'ES' => 'Spain', + 'LK' => 'Sri Lanka', + 'SD' => 'Sudan', + 'SS' => 'South Sudan', + 'SR' => 'Suriname', + 'SJ' => 'Svalbard And Jan Mayen', + 'SZ' => 'Swaziland', + 'SE' => 'Sweden', + 'CH' => 'Switzerland', + 'SY' => 'Syrian Arab Republic', + 'TW' => 'Taiwan', + 'TJ' => 'Tajikistan', + 'TZ' => 'Tanzania', + 'TH' => 'Thailand', + 'TL' => 'Timor-Leste', + 'TG' => 'Togo', + 'TK' => 'Tokelau', + 'TO' => 'Tonga', + 'TT' => 'Trinidad And Tobago', + 'TN' => 'Tunisia', + 'TR' => 'Turkey', + 'TM' => 'Turkmenistan', + 'TC' => 'Turks And Caicos Islands', + 'TV' => 'Tuvalu', + 'UG' => 'Uganda', + 'UA' => 'Ukraine', + 'AE' => 'United Arab Emirates', + 'GB' => 'United Kingdom', + 'US' => 'United States', + 'UM' => 'United States Outlying Islands', + 'UY' => 'Uruguay', + 'UZ' => 'Uzbekistan', + 'VU' => 'Vanuatu', + 'VE' => 'Venezuela', + 'VN' => 'Viet Nam', + 'VG' => 'Virgin Islands, British', + 'VI' => 'Virgin Islands, U.S.', + 'WF' => 'Wallis And Futuna', + 'EH' => 'Western Sahara', + 'YE' => 'Yemen', + 'ZM' => 'Zambia', + 'ZW' => 'Zimbabwe', + ], +]; diff --git a/config/auth.php b/config/auth.php new file mode 100644 index 000000000..c3de50230 --- /dev/null +++ b/config/auth.php @@ -0,0 +1,121 @@ + [ + 'guard' => env('AUTH_GUARD', 'web'), + 'passwords' => env('AUTH_PASSWORD_BROKER', 'users'), + ], + + /* + |-------------------------------------------------------------------------- + | Authentication Guards + |-------------------------------------------------------------------------- + | + | Next, you may define every authentication guard for your application. + | Of course, a great default configuration has been defined for you + | which utilizes session storage plus the Eloquent user provider. + | + | All authentication guards have a user provider, which defines how the + | users are actually retrieved out of your database or other storage + | system used by the application. Typically, Eloquent is utilized. + | + | Supported: "session" + | + */ + + 'guards' => [ + 'web' => [ + 'driver' => 'session', + 'provider' => 'users', + ], + + 'api' => [ + 'driver' => 'passport', + 'provider' => 'users', + 'hash' => true, + ], + ], + + /* + |-------------------------------------------------------------------------- + | User Providers + |-------------------------------------------------------------------------- + | + | All authentication guards have a user provider, which defines how the + | users are actually retrieved out of your database or other storage + | system used by the application. Typically, Eloquent is utilized. + | + | If you have multiple user tables or models you may configure multiple + | providers to represent the model / table. These providers may then + | be assigned to any extra authentication guards you have defined. + | + | Supported: "database", "eloquent" + | + */ + + 'providers' => [ + 'users' => [ + 'driver' => 'eloquent', + 'model' => env('AUTH_MODEL', App\Models\User::class), + ], + + // 'users' => [ + // 'driver' => 'database', + // 'table' => 'users', + // ], + ], + + /* + |-------------------------------------------------------------------------- + | Resetting Passwords + |-------------------------------------------------------------------------- + | + | These configuration options specify the behavior of Laravel's password + | reset functionality, including the table utilized for token storage + | and the user provider that is invoked to actually retrieve users. + | + | The expiry time is the number of minutes that each reset token will be + | considered valid. This security feature keeps tokens short-lived so + | they have less time to be guessed. You may change this as needed. + | + | The throttle setting is the number of seconds a user must wait before + | generating more password reset tokens. This prevents the user from + | quickly generating a very large amount of password reset tokens. + | + */ + + 'passwords' => [ + 'users' => [ + 'provider' => 'users', + 'table' => env('AUTH_PASSWORD_RESET_TOKEN_TABLE', 'password_reset_tokens'), + 'expire' => 60, + 'throttle' => 60, + ], + ], + + /* + |-------------------------------------------------------------------------- + | Password Confirmation Timeout + |-------------------------------------------------------------------------- + | + | Here you may define the amount of seconds before a password confirmation + | window expires and users are asked to re-enter their password via the + | confirmation screen. By default, the timeout lasts for three hours. + | + */ + + 'password_timeout' => env('AUTH_PASSWORD_TIMEOUT', 10800), + +]; diff --git a/config/cache.php b/config/cache.php new file mode 100644 index 000000000..51cb1be63 --- /dev/null +++ b/config/cache.php @@ -0,0 +1,107 @@ + env('CACHE_STORE', 'file'), + + /* + |-------------------------------------------------------------------------- + | Cache Stores + |-------------------------------------------------------------------------- + | + | Here you may define all of the cache "stores" for your application as + | well as their drivers. You may even define multiple stores for the + | same cache driver to group types of items stored in your caches. + | + | Supported drivers: "apc", "array", "database", "file", "memcached", + | "redis", "dynamodb", "octane", "null" + | + */ + + 'stores' => [ + + 'array' => [ + 'driver' => 'array', + 'serialize' => false, + ], + + 'database' => [ + 'driver' => 'database', + 'table' => env('DB_CACHE_TABLE', 'cache'), + 'connection' => env('DB_CACHE_CONNECTION', null), + 'lock_connection' => env('DB_CACHE_LOCK_CONNECTION', null), + ], + + 'file' => [ + 'driver' => 'file', + 'path' => storage_path('framework/cache/data'), + 'lock_path' => storage_path('framework/cache/data'), + ], + + 'memcached' => [ + 'driver' => 'memcached', + 'persistent_id' => env('MEMCACHED_PERSISTENT_ID'), + 'sasl' => [ + env('MEMCACHED_USERNAME'), + env('MEMCACHED_PASSWORD'), + ], + 'options' => [ + // Memcached::OPT_CONNECT_TIMEOUT => 2000, + ], + 'servers' => [ + [ + 'host' => env('MEMCACHED_HOST', '127.0.0.1'), + 'port' => env('MEMCACHED_PORT', 11211), + 'weight' => 100, + ], + ], + ], + + 'redis' => [ + 'driver' => 'redis', + 'connection' => env('REDIS_CACHE_CONNECTION', 'cache'), + 'lock_connection' => env('REDIS_CACHE_LOCK_CONNECTION', 'default'), + ], + + 'dynamodb' => [ + 'driver' => 'dynamodb', + 'key' => env('AWS_ACCESS_KEY_ID'), + 'secret' => env('AWS_SECRET_ACCESS_KEY'), + 'region' => env('AWS_DEFAULT_REGION', 'us-east-1'), + 'table' => env('DYNAMODB_CACHE_TABLE', 'cache'), + 'endpoint' => env('DYNAMODB_ENDPOINT'), + ], + + 'octane' => [ + 'driver' => 'octane', + ], + + ], + + /* + |-------------------------------------------------------------------------- + | Cache Key Prefix + |-------------------------------------------------------------------------- + | + | When utilizing the APC, database, memcached, Redis, and DynamoDB cache + | stores, there might be other applications using the same cache. For + | that reason, you may prefix every cache key to avoid collisions. + | + */ + + 'prefix' => env('CACHE_PREFIX', Str::slug(env('APP_NAME', 'laravel'), '_') . '_cache_'), + +]; diff --git a/config/cookie-consent.php b/config/cookie-consent.php new file mode 100644 index 000000000..4f4f16624 --- /dev/null +++ b/config/cookie-consent.php @@ -0,0 +1,20 @@ + env('COOKIE_CONSENT_ENABLED', true), + + /* + * The name of the cookie in which we store if the user + * has agreed to accept the conditions. + */ + 'cookie_name' => 'laravel_cookie_consent', + + /* + * Set the cookie duration in days. Default is 365 * 20. + */ + 'cookie_lifetime' => 365 * 20, +]; diff --git a/config/cookies-consent.php b/config/cookies-consent.php new file mode 100644 index 000000000..6e6915e76 --- /dev/null +++ b/config/cookies-consent.php @@ -0,0 +1,59 @@ + [ + 'domain' => null, + 'prefix' => 'cookiesconsent', + ], + + /* + |-------------------------------------------------------------------------- + | Consent cookie configuration + |-------------------------------------------------------------------------- + | + | In order to keep track of the user's preferences, this package stores + | an anonymized cookie. You do not need to register this cookie in the + | package's cookie manager as it is done automatically (under "essentials"). + | + | The duration parameter represents the cookie's lifetime in minutes. + | + | The domain parameter, when defined, determines the cookie's activity domain. + | For multiple sub-domains, prefix your domain with "." (eg: ".mydomain.com"). + | + */ + + 'cookie' => [ + 'name' => Str::slug((string) env('APP_NAME', 'laravel'), '_').'_cookiesconsent', + 'lifetime' => 60 * 24 * 365, + 'domain' => null, + ], + + /* + |-------------------------------------------------------------------------- + | Legal page configuration + |-------------------------------------------------------------------------- + | + | Most cookie notices display a link to a dedicated page explaining + | the extended cookies usage policy. If your application has such a page + | you can add its route name here. + | + */ + + 'policy' => null, + +]; diff --git a/config/corerouter.php b/config/corerouter.php new file mode 100644 index 000000000..23ffca09e --- /dev/null +++ b/config/corerouter.php @@ -0,0 +1,30 @@ + rtrim(env('COREROUTER_API_BASE_URL', ''), '/'), + + 'api_key' => env('COREROUTER_API_KEY', ''), + + 'username' => env('COREROUTER_API_USERNAME', ''), + + 'password' => env('COREROUTER_API_PASSWORD', ''), + + 'verify_ssl' => filter_var(env('COREROUTER_API_VERIFY_SSL', true), FILTER_VALIDATE_BOOLEAN), + + 'timeout' => (int) env('COREROUTER_API_TIMEOUT', 10), + + 'state_limit' => (int) env('COREROUTER_API_STATE_LIMIT', 1000), +]; + diff --git a/config/database.php b/config/database.php new file mode 100644 index 000000000..751b1f9b8 --- /dev/null +++ b/config/database.php @@ -0,0 +1,170 @@ + env('DB_CONNECTION', 'sqlite'), + + /* + |-------------------------------------------------------------------------- + | Database Connections + |-------------------------------------------------------------------------- + | + | Below are all of the database connections defined for your application. + | An example configuration is provided for each database system which + | is supported by Laravel. You're free to add / remove connections. + | + */ + + 'connections' => [ + + 'sqlite' => [ + 'driver' => 'sqlite', + 'url' => env('DB_URL'), + 'database' => env('DB_DATABASE', database_path('database.sqlite')), + 'prefix' => '', + 'foreign_key_constraints' => env('DB_FOREIGN_KEYS', true), + ], + + 'mysql' => [ + 'driver' => 'mysql', + 'url' => env('DB_URL'), + 'host' => env('DB_HOST', '127.0.0.1'), + 'port' => env('DB_PORT', '3306'), + 'database' => env('DB_DATABASE', 'paymenter'), + 'username' => env('DB_USERNAME', 'paymenter'), + 'password' => env('DB_PASSWORD', ''), + 'unix_socket' => env('DB_SOCKET', ''), + 'charset' => 'utf8mb4', + 'collation' => 'utf8mb4_0900_ai_ci', + 'prefix' => '', + 'prefix_indexes' => true, + 'strict' => true, + 'engine' => null, + 'options' => extension_loaded('pdo_mysql') ? array_filter([ + PDO::MYSQL_ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'), + ]) : [], + ], + + 'mariadb' => [ + 'driver' => 'mariadb', + 'url' => env('DB_URL'), + 'host' => env('DB_HOST', '127.0.0.1'), + 'port' => env('DB_PORT', '3306'), + 'database' => env('DB_DATABASE', 'paymenter'), + 'username' => env('DB_USERNAME', 'paymenter'), + 'password' => env('DB_PASSWORD', ''), + 'unix_socket' => env('DB_SOCKET', ''), + 'charset' => 'utf8mb4', + 'collation' => env('DB_COLLATION', 'utf8mb4_uca1400_ai_ci'), + 'prefix' => '', + 'prefix_indexes' => true, + 'strict' => true, + 'engine' => null, + 'options' => extension_loaded('pdo_mysql') ? array_filter([ + PDO::MYSQL_ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'), + ]) : [], + ], + + 'pgsql' => [ + 'driver' => 'pgsql', + 'url' => env('DB_URL'), + 'host' => env('DB_HOST', '127.0.0.1'), + 'port' => env('DB_PORT', '5432'), + 'database' => env('DB_DATABASE', 'laravel'), + 'username' => env('DB_USERNAME', 'root'), + 'password' => env('DB_PASSWORD', ''), + 'charset' => 'utf8', + 'prefix' => '', + 'prefix_indexes' => true, + 'search_path' => 'public', + 'sslmode' => 'prefer', + ], + + 'sqlsrv' => [ + 'driver' => 'sqlsrv', + 'url' => env('DB_URL'), + 'host' => env('DB_HOST', 'localhost'), + 'port' => env('DB_PORT', '1433'), + 'database' => env('DB_DATABASE', 'laravel'), + 'username' => env('DB_USERNAME', 'root'), + 'password' => env('DB_PASSWORD', ''), + 'charset' => 'utf8', + 'prefix' => '', + 'prefix_indexes' => true, + // 'encrypt' => env('DB_ENCRYPT', 'yes'), + // 'trust_server_certificate' => env('DB_TRUST_SERVER_CERTIFICATE', 'false'), + ], + + ], + + /* + |-------------------------------------------------------------------------- + | Migration Repository Table + |-------------------------------------------------------------------------- + | + | This table keeps track of all the migrations that have already run for + | your application. Using this information, we can determine which of + | the migrations on disk haven't actually been run on the database. + | + */ + + 'migrations' => [ + 'table' => 'migrations', + 'update_date_on_publish' => true, + ], + + /* + |-------------------------------------------------------------------------- + | Redis Databases + |-------------------------------------------------------------------------- + | + | Redis is an open source, fast, and advanced key-value store that also + | provides a richer body of commands than a typical key-value system + | such as Memcached. You may define your connection settings here. + | + */ + + 'redis' => [ + + 'client' => env('REDIS_CLIENT', 'phpredis'), + + 'options' => [ + 'cluster' => env('REDIS_CLUSTER', 'redis'), + 'prefix' => env('REDIS_PREFIX', Str::slug(env('APP_NAME', 'laravel'), '_') . '_database_'), + ], + + 'default' => [ + 'url' => env('REDIS_URL'), + 'host' => env('REDIS_HOST', '127.0.0.1'), + 'username' => env('REDIS_USERNAME'), + 'password' => env('REDIS_PASSWORD'), + 'port' => env('REDIS_PORT', '6379'), + 'database' => env('REDIS_DB', '0'), + ], + + 'cache' => [ + 'url' => env('REDIS_URL'), + 'host' => env('REDIS_HOST', '127.0.0.1'), + 'username' => env('REDIS_USERNAME'), + 'password' => env('REDIS_PASSWORD'), + 'port' => env('REDIS_PORT', '6379'), + 'database' => env('REDIS_CACHE_DB', '1'), + ], + + ], + +]; diff --git a/config/filesystems.php b/config/filesystems.php new file mode 100644 index 000000000..6f3aeeda8 --- /dev/null +++ b/config/filesystems.php @@ -0,0 +1,83 @@ + env('FILESYSTEM_DISK', 'local'), + + /* + |-------------------------------------------------------------------------- + | Filesystem Disks + |-------------------------------------------------------------------------- + | + | Below you may configure as many filesystem disks as necessary, and you + | may even configure multiple disks for the same driver. Examples for + | most supported storage drivers are configured here for reference. + | + | Supported Drivers: "local", "ftp", "sftp", "s3" + | + */ + + 'disks' => [ + + 'local' => [ + 'driver' => 'local', + 'root' => storage_path('app'), + 'throw' => false, + ], + + 'downloads' => [ + 'driver' => 'local', + 'root' => storage_path('app/downloads'), + 'visibility' => 'private', + 'throw' => false, + ], + + 'public' => [ + 'driver' => 'local', + 'root' => storage_path('app/public'), + 'url' => env('APP_URL') . '/storage', + 'visibility' => 'public', + 'throw' => false, + ], + + 's3' => [ + 'driver' => 's3', + 'key' => env('AWS_ACCESS_KEY_ID'), + 'secret' => env('AWS_SECRET_ACCESS_KEY'), + 'region' => env('AWS_DEFAULT_REGION'), + 'bucket' => env('AWS_BUCKET'), + 'url' => env('AWS_URL'), + 'endpoint' => env('AWS_ENDPOINT'), + 'use_path_style_endpoint' => env('AWS_USE_PATH_STYLE_ENDPOINT', false), + 'throw' => false, + ], + + ], + + /* + |-------------------------------------------------------------------------- + | Symbolic Links + |-------------------------------------------------------------------------- + | + | Here you may configure the symbolic links that will be created when the + | `storage:link` Artisan command is executed. The array keys should be + | the locations of the links and the values should be their targets. + | + */ + + 'links' => [ + public_path('storage') => storage_path('app/public'), + ], + +]; diff --git a/config/filesystems.php.bak b/config/filesystems.php.bak new file mode 100644 index 000000000..40b7e787d --- /dev/null +++ b/config/filesystems.php.bak @@ -0,0 +1,76 @@ + env('FILESYSTEM_DISK', 'local'), + + /* + |-------------------------------------------------------------------------- + | Filesystem Disks + |-------------------------------------------------------------------------- + | + | Below you may configure as many filesystem disks as necessary, and you + | may even configure multiple disks for the same driver. Examples for + | most supported storage drivers are configured here for reference. + | + | Supported Drivers: "local", "ftp", "sftp", "s3" + | + */ + + 'disks' => [ + + 'local' => [ + 'driver' => 'local', + 'root' => storage_path('app'), + 'throw' => false, + ], + + 'public' => [ + 'driver' => 'local', + 'root' => storage_path('app/public'), + 'url' => env('APP_URL') . '/storage', + 'visibility' => 'public', + 'throw' => false, + ], + + 's3' => [ + 'driver' => 's3', + 'key' => env('AWS_ACCESS_KEY_ID'), + 'secret' => env('AWS_SECRET_ACCESS_KEY'), + 'region' => env('AWS_DEFAULT_REGION'), + 'bucket' => env('AWS_BUCKET'), + 'url' => env('AWS_URL'), + 'endpoint' => env('AWS_ENDPOINT'), + 'use_path_style_endpoint' => env('AWS_USE_PATH_STYLE_ENDPOINT', false), + 'throw' => false, + ], + + ], + + /* + |-------------------------------------------------------------------------- + | Symbolic Links + |-------------------------------------------------------------------------- + | + | Here you may configure the symbolic links that will be created when the + | `storage:link` Artisan command is executed. The array keys should be + | the locations of the links and the values should be their targets. + | + */ + + 'links' => [ + public_path('storage') => storage_path('app/public'), + ], + +]; diff --git a/config/laravel-cookie-consent.php b/config/laravel-cookie-consent.php new file mode 100644 index 000000000..78ae28a85 --- /dev/null +++ b/config/laravel-cookie-consent.php @@ -0,0 +1,239 @@ + env('APP_NAME', 'Laravel_App'), + + /** + * Enable or disable the cookie consent banner + * + * @default true + * @env COOKIE_CONSENT_ENABLED + */ + 'enabled' => env('COOKIE_CONSENT_ENABLED', true), + + /** + * Cookie lifetime in days + * + * Defines how long the consent cookie should persist in the user's browser. + * + * @default 365 + * @env COOKIE_CONSENT_LIFETIME + */ + 'cookie_lifetime' => env('COOKIE_CONSENT_LIFETIME', 365), + + /** + * Rejection cookie lifetime in days + * + * Specifies how long the rejection cookie should persist when users decline cookies. + * + * @default 7 + * @env COOKIE_REJECT_LIFETIME + */ + 'reject_lifetime' => env('COOKIE_REJECT_LIFETIME', 7), + + /** + * Consent modal layout style + * + * Determines the visual presentation of the consent modal. + * + * @default 'bar-inline' + * @env COOKIE_CONSENT_MODAL_LAYOUT + * @option box - Small floating box + * @option box-inline - Small floating box positioned inline + * @option box-wide - Larger floating box + * @option cloud - Cloud-like floating consent box + * @option cloud-inline - Compact cloud-style box + * @option bar - Simple bar at top or bottom + * @option bar-inline - Compact inline bar + */ + 'consent_modal_layout' => env('COOKIE_CONSENT_MODAL_LAYOUT', 'bar'), + + /** + * Enable preferences modal + * + * Determines if users can access detailed cookie preferences. + * + * @default false + * @env COOKIE_CONSENT_PREFERENCES_ENABLED + */ + 'preferences_modal_enabled' => env('COOKIE_CONSENT_PREFERENCES_ENABLED', true), + + /** + * Preferences modal layout style + * + * Defines the visual presentation of the preferences modal. + * + * @default 'bar' + * @env COOKIE_CONSENT_PREFERENCES_LAYOUT + * @option bar - Bar-style modal + * @option box - Popup-style box + */ + 'preferences_modal_layout' => env('COOKIE_CONSENT_PREFERENCES_LAYOUT', 'bar'), + + /** + * Enable flip button animation + * + * Adds a flip animation effect to consent buttons. + * + * @default true + * @env COOKIE_CONSENT_FLIP_BUTTON + */ + 'flip_button' => env('COOKIE_CONSENT_FLIP_BUTTON', true), + + /** + * Disable page interaction until consent + * + * When enabled, users must interact with the cookie banner before accessing content. + * + * @default true + * @env COOKIE_CONSENT_DISABLE_INTERACTION + */ + 'disable_page_interaction' => env('COOKIE_CONSENT_DISABLE_INTERACTION', true), + + /** + * Color theme for the cookie banner + * + * @default 'default' + * @env COOKIE_CONSENT_THEME + * @option default - Standard theme + * @option dark - Dark mode theme + * @option light - Light mode theme + * @option custom - Custom styles (requires additional CSS) + */ + 'theme' => env('COOKIE_CONSENT_THEME', 'default'), + + /** + * Cookie banner title text + * + * @default "Cookie Disclaimer" + */ + 'cookie_title' => "Cookie Disclaimer", + + /** + * Cookie banner description text + * + * @default "This website uses cookies to enhance your browsing experience, analyze site traffic, and personalize content. By continuing to use this site, you consent to our use of cookies." + */ + 'cookie_description' => "This website uses cookies to enhance your browsing experience, analyze site traffic, and personalize content. By continuing to use this site, you consent to our use of cookies.", + + /** + * Accept all cookies button text + * + * @default 'Accept all' + */ + 'cookie_accept_btn_text' => 'Accept all', + + /** + * Reject all cookies button text + * + * @default 'Reject all' + */ + 'cookie_reject_btn_text' => 'Reject all', + + /** + * Manage preferences button text + * + * @default 'Manage preferences' + */ + 'cookie_preferences_btn_text' => 'Manage preferences', + + /** + * Save preferences button text + * + * @default 'Save preferences' + */ + 'cookie_preferences_save_text' => 'Save preferences', + + /** + * Preferences modal title text + * + * @default 'Cookie Preferences' + */ + 'cookie_modal_title' => 'Cookie Preferences', + + /** + * Preferences modal introduction text + * + * @default 'You can customize your cookie preferences below.' + */ + 'cookie_modal_intro' => 'You can customize your cookie preferences below.', + + /** + * Cookie categories configuration + * + * Defines the different types of cookies users can manage. + * + * @category necessary - Essential cookies that cannot be disabled + * @category analytics - Cookies used for tracking and analytics + * @category marketing - Cookies used for advertising + * @category preferences - Cookies for user preference storage + */ + 'cookie_categories' => [ + 'necessary' => [ + 'enabled' => true, + 'locked' => true, + 'title' => 'Essential Cookies', + 'description' => 'These cookies are essential for the website to function properly.', + ], + 'analytics' => [ + 'enabled' => env('COOKIE_CONSENT_ANALYTICS', false), + 'locked' => false, + 'js_action' => 'loadGoogleAnalytics', + 'title' => 'Analytics Cookies', + 'description' => 'These cookies help us understand how visitors interact with our website.', + ], + 'marketing' => [ + 'enabled' => env('COOKIE_CONSENT_MARKETING', false), + 'locked' => false, + 'js_action' => 'loadFacebookPixel', + 'title' => 'Marketing Cookies', + 'description' => 'These cookies are used for advertising and tracking purposes.', + ], + 'preferences' => [ + 'enabled' => env('COOKIE_CONSENT_PREFERENCES', false), + 'locked' => false, + 'title' => 'Preferences Cookies', + 'description' => 'These cookies allow the website to remember user preferences.', + ], + ], + + /** + * Policy links configuration + * + * Links to legal documents displayed in the cookie banner. + * + * @item text - Display text for the link + * @item link - URL to the policy document + */ + 'policy_links' => [ + [ + 'text' => 'Privacy Policy', + 'link' => env('COOKIE_CONSENT_PRIVACY_POLICY_URL', '') ?? url('privacy-policy') + ], + [ + 'text' => 'Terms and Conditions', + 'link' => env('COOKIE_CONSENT_TERMS_URL', '') ?? url('terms-and-conditions') + ], + ], +]; + diff --git a/config/livewire.php b/config/livewire.php new file mode 100644 index 000000000..12c79d252 --- /dev/null +++ b/config/livewire.php @@ -0,0 +1,159 @@ + 'App\\Livewire', + + /* + |--------------------------------------------------------------------------- + | View Path + |--------------------------------------------------------------------------- + | + | This value is used to specify where Livewire component Blade templates are + | stored when running file creation commands like `artisan make:livewire`. + | It is also used if you choose to omit a component's render() method. + | + */ + + 'view_path' => base_path('themes/default/views'), + + /* + |--------------------------------------------------------------------------- + | Layout + |--------------------------------------------------------------------------- + | The view that will be used as the layout when rendering a single component + | as an entire page via `Route::get('/post/create', CreatePost::class);`. + | In this case, the view returned by CreatePost will render into $slot. + | + */ + + 'layout' => 'layouts.app', + + /* + |--------------------------------------------------------------------------- + | Lazy Loading Placeholder + |--------------------------------------------------------------------------- + | Livewire allows you to lazy load components that would otherwise slow down + | the initial page load. Every component can have a custom placeholder or + | you can define the default placeholder view for all components below. + | + */ + + 'lazy_placeholder' => null, + + /* + |--------------------------------------------------------------------------- + | Temporary File Uploads + |--------------------------------------------------------------------------- + | + | Livewire handles file uploads by storing uploads in a temporary directory + | before the file is stored permanently. All file uploads are directed to + | a global endpoint for temporary storage. You may configure this below: + | + */ + + 'temporary_file_upload' => [ + 'disk' => null, // Example: 'local', 's3' | Default: 'default' + 'rules' => null, // Example: ['file', 'mimes:png,jpg'] | Default: ['required', 'file', 'max:12288'] (12MB) + 'directory' => null, // Example: 'tmp' | Default: 'livewire-tmp' + 'middleware' => null, // Example: 'throttle:5,1' | Default: 'throttle:60,1' + 'preview_mimes' => [ // Supported file types for temporary pre-signed file URLs... + 'png', 'gif', 'bmp', 'svg', 'wav', 'mp4', + 'mov', 'avi', 'wmv', 'mp3', 'm4a', + 'jpg', 'jpeg', 'mpga', 'webp', 'wma', + ], + 'max_upload_time' => 5, // Max duration (in minutes) before an upload is invalidated... + ], + + /* + |--------------------------------------------------------------------------- + | Render On Redirect + |--------------------------------------------------------------------------- + | + | This value determines if Livewire will run a component's `render()` method + | after a redirect has been triggered using something like `redirect(...)` + | Setting this to true will render the view once more before redirecting + | + */ + + 'render_on_redirect' => false, + + /* + |--------------------------------------------------------------------------- + | Eloquent Model Binding + |--------------------------------------------------------------------------- + | + | Previous versions of Livewire supported binding directly to eloquent model + | properties using wire:model by default. However, this behavior has been + | deemed too "magical" and has therefore been put under a feature flag. + | + */ + + 'legacy_model_binding' => false, + + /* + |--------------------------------------------------------------------------- + | Auto-inject Frontend Assets + |--------------------------------------------------------------------------- + | + | By default, Livewire automatically injects its JavaScript and CSS into the + | and of pages containing Livewire components. By disabling + | this behavior, you need to use @livewireStyles and @livewireScripts. + | + */ + + 'inject_assets' => true, + + /* + |--------------------------------------------------------------------------- + | Navigate (SPA mode) + |--------------------------------------------------------------------------- + | + | By adding `wire:navigate` to links in your Livewire application, Livewire + | will prevent the default link handling and instead request those pages + | via AJAX, creating an SPA-like effect. Configure this behavior here. + | + */ + + 'navigate' => [ + 'show_progress_bar' => true, + 'progress_bar_color' => 'hsl(var(--color-primary))', + ], + + /* + |--------------------------------------------------------------------------- + | HTML Morph Markers + |--------------------------------------------------------------------------- + | + | Livewire intelligently "morphs" existing HTML into the newly rendered HTML + | after each update. To make this process more reliable, Livewire injects + | "markers" into the rendered Blade surrounding @if, @class & @foreach. + | + */ + + 'inject_morph_markers' => true, + + /* + |--------------------------------------------------------------------------- + | Pagination Theme + |--------------------------------------------------------------------------- + | + | When enabling Livewire's pagination feature by using the `WithPagination` + | trait, Livewire will use Tailwind templates to render pagination views + | on the page. If you want Bootstrap CSS, you can specify: "bootstrap" + | + */ + + 'pagination_theme' => 'tailwind', +]; diff --git a/config/logging.php b/config/logging.php new file mode 100644 index 000000000..d3f62c42e --- /dev/null +++ b/config/logging.php @@ -0,0 +1,132 @@ + env('LOG_CHANNEL', 'stack'), + + /* + |-------------------------------------------------------------------------- + | Deprecations Log Channel + |-------------------------------------------------------------------------- + | + | This option controls the log channel that should be used to log warnings + | regarding deprecated PHP and library features. This allows you to get + | your application ready for upcoming major versions of dependencies. + | + */ + + 'deprecations' => [ + 'channel' => env('LOG_DEPRECATIONS_CHANNEL', 'null'), + 'trace' => env('LOG_DEPRECATIONS_TRACE', false), + ], + + /* + |-------------------------------------------------------------------------- + | Log Channels + |-------------------------------------------------------------------------- + | + | Here you may configure the log channels for your application. Laravel + | utilizes the Monolog PHP logging library, which includes a variety + | of powerful log handlers and formatters that you're free to use. + | + | Available Drivers: "single", "daily", "slack", "syslog", + | "errorlog", "monolog", "custom", "stack" + | + */ + + 'channels' => [ + + 'stack' => [ + 'driver' => 'stack', + 'channels' => ['daily'], + 'ignore_exceptions' => false, + ], + + 'single' => [ + 'driver' => 'single', + 'path' => storage_path('logs/laravel.log'), + 'level' => env('LOG_LEVEL', 'debug'), + 'replace_placeholders' => true, + ], + + 'daily' => [ + 'driver' => 'daily', + 'path' => storage_path('logs/laravel.log'), + 'level' => env('LOG_LEVEL', 'debug'), + 'days' => env('LOG_DAILY_DAYS', 14), + 'replace_placeholders' => true, + ], + + 'slack' => [ + 'driver' => 'slack', + 'url' => env('LOG_SLACK_WEBHOOK_URL'), + 'username' => env('LOG_SLACK_USERNAME', 'Laravel Log'), + 'emoji' => env('LOG_SLACK_EMOJI', ':boom:'), + 'level' => env('LOG_LEVEL', 'critical'), + 'replace_placeholders' => true, + ], + + 'papertrail' => [ + 'driver' => 'monolog', + 'level' => env('LOG_LEVEL', 'debug'), + 'handler' => env('LOG_PAPERTRAIL_HANDLER', SyslogUdpHandler::class), + 'handler_with' => [ + 'host' => env('PAPERTRAIL_URL'), + 'port' => env('PAPERTRAIL_PORT'), + 'connectionString' => 'tls://' . env('PAPERTRAIL_URL') . ':' . env('PAPERTRAIL_PORT'), + ], + 'processors' => [PsrLogMessageProcessor::class], + ], + + 'stderr' => [ + 'driver' => 'monolog', + 'level' => env('LOG_LEVEL', 'debug'), + 'handler' => StreamHandler::class, + 'formatter' => env('LOG_STDERR_FORMATTER'), + 'with' => [ + 'stream' => 'php://stderr', + ], + 'processors' => [PsrLogMessageProcessor::class], + ], + + 'syslog' => [ + 'driver' => 'syslog', + 'level' => env('LOG_LEVEL', 'debug'), + 'facility' => env('LOG_SYSLOG_FACILITY', LOG_USER), + 'replace_placeholders' => true, + ], + + 'errorlog' => [ + 'driver' => 'errorlog', + 'level' => env('LOG_LEVEL', 'debug'), + 'replace_placeholders' => true, + ], + + 'null' => [ + 'driver' => 'monolog', + 'handler' => NullHandler::class, + ], + + 'emergency' => [ + 'path' => storage_path('logs/laravel.log'), + ], + + ], + +]; diff --git a/config/mail.php b/config/mail.php new file mode 100644 index 000000000..bf685c3db --- /dev/null +++ b/config/mail.php @@ -0,0 +1,93 @@ + env('MAIL_MAILER', 'smtp'), + + /* + |-------------------------------------------------------------------------- + | Mailer Configurations + |-------------------------------------------------------------------------- + | + | Here you may configure all of the mailers used by your application plus + | their respective settings. Several examples have been configured for + | you and you are free to add your own as your application requires. + | + | Laravel supports a variety of mail "transport" drivers to be used while + | delivering an email. You may specify which one you're using for your + | mailers below. You are free to add additional mailers as required. + | + | Supported: "smtp", "sendmail", "mailgun", "ses", "ses-v2", + | "postmark", "log", "array", "failover", "roundrobin" + | + */ + + 'mailers' => [ + 'smtp' => [ + 'transport' => 'smtp', + 'url' => env('MAIL_URL'), + 'host' => env('MAIL_HOST', '127.0.0.1'), + 'port' => env('MAIL_PORT', 2525), + 'encryption' => env('MAIL_ENCRYPTION', 'tls'), + 'username' => env('MAIL_USERNAME'), + 'password' => env('MAIL_PASSWORD'), + 'timeout' => null, + 'local_domain' => env('MAIL_EHLO_DOMAIN', parse_url(env('APP_URL', 'http://localhost'), PHP_URL_HOST)), + ], + + 'failover' => [ + 'transport' => 'failover', + 'mailers' => [ + 'smtp', + 'log', + ], + ], + ], + + /* + |-------------------------------------------------------------------------- + | Global "From" Address + |-------------------------------------------------------------------------- + | + | You may wish for all emails sent by your application to be sent from + | the same address. Here you may specify a name and address that is + | used globally for all emails that are sent by your application. + | + */ + + 'from' => [ + 'address' => env('MAIL_FROM_ADDRESS', 'hello@example.com'), + 'name' => env('MAIL_FROM_NAME', 'Example'), + ], + + /* + |-------------------------------------------------------------------------- + | Markdown Mail Settings + |-------------------------------------------------------------------------- + | + | If you are using Markdown based email rendering, you may configure your + | theme and component paths here, allowing you to customize the design + | of the emails. Or, you may simply stick with the Laravel defaults! + | + */ + + 'markdown' => [ + 'theme' => env('MAIL_MARKDOWN_THEME', 'default'), + + 'paths' => [ + resource_path('views/vendor/mail'), + ], + ], + +]; diff --git a/config/octane.php b/config/octane.php new file mode 100644 index 000000000..8cfba0114 --- /dev/null +++ b/config/octane.php @@ -0,0 +1,224 @@ + env('OCTANE_SERVER', 'roadrunner'), + + /* + |-------------------------------------------------------------------------- + | Force HTTPS + |-------------------------------------------------------------------------- + | + | When this configuration value is set to "true", Octane will inform the + | framework that all absolute links must be generated using the HTTPS + | protocol. Otherwise your links may be generated using plain HTTP. + | + */ + + 'https' => env('OCTANE_HTTPS', false), + + /* + |-------------------------------------------------------------------------- + | Octane Listeners + |-------------------------------------------------------------------------- + | + | All of the event listeners for Octane's events are defined below. These + | listeners are responsible for resetting your application's state for + | the next request. You may even add your own listeners to the list. + | + */ + + 'listeners' => [ + WorkerStarting::class => [ + EnsureUploadedFilesAreValid::class, + EnsureUploadedFilesCanBeMoved::class, + ], + + RequestReceived::class => [ + ...Octane::prepareApplicationForNextOperation(), + ...Octane::prepareApplicationForNextRequest(), + // + ], + + RequestHandled::class => [ + // + ], + + RequestTerminated::class => [ + // FlushUploadedFiles::class, + ], + + TaskReceived::class => [ + ...Octane::prepareApplicationForNextOperation(), + // + ], + + TaskTerminated::class => [ + // + ], + + TickReceived::class => [ + ...Octane::prepareApplicationForNextOperation(), + // + ], + + TickTerminated::class => [ + // + ], + + OperationTerminated::class => [ + FlushOnce::class, + FlushTemporaryContainerInstances::class, + // DisconnectFromDatabases::class, + // CollectGarbage::class, + ], + + WorkerErrorOccurred::class => [ + ReportException::class, + StopWorkerIfNecessary::class, + ], + + WorkerStopping::class => [ + CloseMonologHandlers::class, + ], + ], + + /* + |-------------------------------------------------------------------------- + | Warm / Flush Bindings + |-------------------------------------------------------------------------- + | + | The bindings listed below will either be pre-warmed when a worker boots + | or they will be flushed before every new request. Flushing a binding + | will force the container to resolve that binding again when asked. + | + */ + + 'warm' => [ + ...Octane::defaultServicesToWarm(), + ], + + 'flush' => [ + // + ], + + /* + |-------------------------------------------------------------------------- + | Octane Swoole Tables + |-------------------------------------------------------------------------- + | + | While using Swoole, you may define additional tables as required by the + | application. These tables can be used to store data that needs to be + | quickly accessed by other workers on the particular Swoole server. + | + */ + + 'tables' => [ + 'example:1000' => [ + 'name' => 'string:1000', + 'votes' => 'int', + ], + ], + + /* + |-------------------------------------------------------------------------- + | Octane Swoole Cache Table + |-------------------------------------------------------------------------- + | + | While using Swoole, you may leverage the Octane cache, which is powered + | by a Swoole table. You may set the maximum number of rows as well as + | the number of bytes per row using the configuration options below. + | + */ + + 'cache' => [ + 'rows' => 1000, + 'bytes' => 10000, + ], + + /* + |-------------------------------------------------------------------------- + | File Watching + |-------------------------------------------------------------------------- + | + | The following list of files and directories will be watched when using + | the --watch option offered by Octane. If any of the directories and + | files are changed, Octane will automatically reload your workers. + | + */ + + 'watch' => [ + 'app', + 'bootstrap', + 'config/**/*.php', + 'database/**/*.php', + 'public/**/*.php', + 'resources/**/*.php', + 'routes', + 'composer.lock', + '.env', + ], + + /* + |-------------------------------------------------------------------------- + | Garbage Collection Threshold + |-------------------------------------------------------------------------- + | + | When executing long-lived PHP scripts such as Octane, memory can build + | up before being cleared by PHP. You can force Octane to run garbage + | collection if your application consumes this amount of megabytes. + | + */ + + 'garbage' => 50, + + /* + |-------------------------------------------------------------------------- + | Maximum Execution Time + |-------------------------------------------------------------------------- + | + | The following setting configures the maximum execution time for requests + | being handled by Octane. You may set this value to 0 to indicate that + | there isn't a specific time limit on Octane request execution time. + | + */ + + 'max_execution_time' => 30, + +]; diff --git a/config/passport.php b/config/passport.php new file mode 100644 index 000000000..972e4e1b4 --- /dev/null +++ b/config/passport.php @@ -0,0 +1,75 @@ + 'web', + + /* + |-------------------------------------------------------------------------- + | Encryption Keys + |-------------------------------------------------------------------------- + | + | Passport uses encryption keys while generating secure access tokens for + | your application. By default, the keys are stored as local files but + | can be set via environment variables when that is more convenient. + | + */ + + 'private_key' => env('PASSPORT_PRIVATE_KEY'), + + 'public_key' => env('PASSPORT_PUBLIC_KEY'), + + /* + |-------------------------------------------------------------------------- + | Passport Database Connection + |-------------------------------------------------------------------------- + | + | By default, Passport's models will utilize your application's default + | database connection. If you wish to use a different connection you + | may specify the configured name of the database connection here. + | + */ + + 'connection' => env('PASSPORT_CONNECTION'), + + /* + |-------------------------------------------------------------------------- + | Client UUIDs + |-------------------------------------------------------------------------- + | + | By default, Passport uses auto-incrementing primary keys when assigning + | IDs to clients. However, if Passport is installed using the provided + | --uuids switch, this will be set to "true" and UUIDs will be used. + | + */ + + 'client_uuids' => true, + + /* + |-------------------------------------------------------------------------- + | Personal Access Client + |-------------------------------------------------------------------------- + | + | If you enable client hashing, you should set the personal access client + | ID and unhashed secret within your environment file. The values will + | get used while issuing fresh personal access tokens to your users. + | + */ + + 'personal_access_client' => [ + 'id' => env('PASSPORT_PERSONAL_ACCESS_CLIENT_ID'), + 'secret' => env('PASSPORT_PERSONAL_ACCESS_CLIENT_SECRET'), + ], + +]; diff --git a/config/permissions.php b/config/permissions.php new file mode 100644 index 000000000..cd12e85b8 --- /dev/null +++ b/config/permissions.php @@ -0,0 +1,230 @@ + [ + '*' => 'All Permissions', + + 'admin' => [ + 'settings' => [ + 'view' => 'View Settings', + 'update' => 'Update Settings', + ], + 'users' => [ + 'create' => 'Create Users', + 'update' => 'Update Users', + 'viewAny' => 'View Users', + 'delete' => 'Delete Users', + 'impersonate' => 'Impersonate Users', + ], + 'invoices' => [ + 'create' => 'Create Invoices', + 'update' => 'Update Invoices', + 'viewAny' => 'View Invoices', + 'delete' => 'Delete Invoices', + 'deleteAny' => 'Bulk Delete Invoices', + ], + 'products' => [ + 'create' => 'Create Products', + 'update' => 'Update Products', + 'viewAny' => 'View Products', + 'delete' => 'Delete Products', + 'deleteAny' => 'Bulk Delete Products', + ], + 'categories' => [ + 'create' => 'Create Categories', + 'update' => 'Update Categories', + 'viewAny' => 'View Categories', + 'delete' => 'Delete Categories', + 'deleteAny' => 'Bulk Delete Categories', + ], + 'tickets' => [ + 'create' => 'Create Tickets', + 'update' => 'Update Tickets', + 'viewAny' => 'View Tickets', + 'delete' => 'Delete Tickets', + 'deleteAny' => 'Bulk Delete Tickets', + ], + 'ticket_messages' => [ + 'delete' => 'Delete Ticket Messages', + ], + 'orders' => [ + 'create' => 'Create Orders', + 'update' => 'Update Orders', + 'viewAny' => 'View Orders', + 'delete' => 'Delete Orders', + 'deleteAny' => 'Bulk Delete Orders', + ], + 'services' => [ + 'create' => 'Create Services', + 'update' => 'Update Services', + 'viewAny' => 'View Services', + 'delete' => 'Delete Services', + 'deleteAny' => 'Bulk Delete Services', + ], + 'service_cancellations' => [ + 'create' => 'Create Service Cancellations', + 'update' => 'Update Service Cancellations', + 'viewAny' => 'View Service Cancellations', + 'delete' => 'Delete Service Cancellations', + 'deleteAny' => 'Bulk Delete Service Cancellations', + ], + 'custom_properties' => [ + 'create' => 'Create Custom Properties', + 'update' => 'Update Custom Properties', + 'viewAny' => 'View Custom Properties', + 'delete' => 'Delete Custom Properties', + 'deleteAny' => 'Bulk Delete Custom Properties', + ], + 'currencies' => [ + 'create' => 'Create Currencies', + 'update' => 'Update Currencies', + 'viewAny' => 'View Currencies', + 'delete' => 'Delete Currencies', + 'deleteAny' => 'Bulk Delete Currencies', + ], + 'roles' => [ + 'create' => 'Create Roles', + 'update' => 'Update Roles', + 'viewAny' => 'View Roles', + 'delete' => 'Delete Roles', + 'deleteAny' => 'Bulk Delete Roles', + ], + 'coupons' => [ + 'create' => 'Create Coupons', + 'update' => 'Update Coupons', + 'viewAny' => 'View Coupons', + 'delete' => 'Delete Coupons', + 'deleteAny' => 'Bulk Delete Coupons', + ], + 'config_options' => [ + 'create' => 'Create Config Options', + 'update' => 'Update Config Options', + 'viewAny' => 'View Config Options', + 'delete' => 'Delete Config Options', + 'deleteAny' => 'Bulk Delete Config Options', + ], + 'tax_rates' => [ + 'create' => 'Create Tax Rates', + 'update' => 'Update Tax Rates', + 'viewAny' => 'View Tax Rates', + 'delete' => 'Delete Tax Rates', + 'deleteAny' => 'Bulk Delete Tax Rates', + ], + 'gateways' => [ + 'create' => 'Create Gateways', + 'update' => 'Update Gateways', + 'viewAny' => 'View Gateways', + 'delete' => 'Delete Gateways', + 'deleteAny' => 'Bulk Delete Gateways', + ], + 'servers' => [ + 'create' => 'Create Servers', + 'update' => 'Update Servers', + 'viewAny' => 'View Servers', + 'delete' => 'Delete Servers', + 'deleteAny' => 'Bulk Delete Servers', + ], + 'api_keys' => [ + 'create' => 'Create API Keys', + 'update' => 'Update API Keys', + 'viewAny' => 'View API Keys', + 'delete' => 'Delete API Keys', + ], + 'extensions' => [ + 'update' => 'Update Extensions', + 'viewAny' => 'View Extensions', + ], + 'failed_jobs' => [ + 'viewAny' => 'View Failed Jobs', + ], + 'email_logs' => [ + 'viewAny' => 'View Email Logs', + 'view' => 'View Email Log', + ], + 'email_templates' => [ + 'create' => 'Create Email Templates', + 'update' => 'Update Email Templates', + 'viewAny' => 'View Email Templates', + 'delete' => 'Delete Email Templates', + 'deleteAny' => 'Bulk Delete Email Templates', + ], + 'oauth_clients' => [ + 'create' => 'Create OAuth Clients', + 'update' => 'Update OAuth Clients', + 'viewAny' => 'View OAuth Clients', + 'delete' => 'Delete OAuth Clients', + 'deleteAny' => 'Bulk Delete OAuth Clients', + ], + 'widgets' => [ + 'revenue' => 'View Revenue Widget', + 'overview' => 'View Overview Widget', + 'support' => 'View Tickets Widget', + 'active_users' => 'View Active Users Widget', + ], + 'updates' => [ + 'update' => 'View and Update Application', + ], + ], + ], + 'api' => [ + 'admin' => [ + 'users' => [ + 'create' => 'Create Users', + 'update' => 'Update Users', + 'view' => 'View Users', + 'delete' => 'Delete Users', + 'impersonate' => 'Impersonate Users', + ], + 'invoices' => [ + 'create' => 'Create Invoices', + 'update' => 'Update Invoices', + 'view' => 'View Invoices', + 'delete' => 'Delete Invoices', + ], + 'tickets' => [ + 'create' => 'Create Tickets', + 'update' => 'Update Tickets', + 'view' => 'View Tickets', + 'delete' => 'Delete Tickets', + ], + 'ticket_messages' => [ + 'create' => 'Create Ticket Messages', + 'view' => 'View Ticket Messages', + 'delete' => 'Delete Ticket Messages', + ], + 'orders' => [ + 'create' => 'Create Orders', + 'update' => 'Update Orders', + 'view' => 'View Orders', + 'delete' => 'Delete Orders', + ], + 'services' => [ + 'create' => 'Create Services', + 'update' => 'Update Services', + 'view' => 'View Services', + 'delete' => 'Delete Services', + ], + 'coupons' => [ + 'create' => 'Create Coupons', + 'update' => 'Update Coupons', + 'view' => 'View Coupons', + 'delete' => 'Delete Coupons', + ], + 'products' => [ + 'view' => 'View Products', + ], + 'categories' => [ + 'view' => 'View Categories', + ], + 'properties' => [ + 'view' => 'View Custom Properties', + ], + 'roles' => [ + 'view' => 'View Roles', + ], + 'credits' => [ + 'view' => 'View Credits', + ], + ], + ], +]; diff --git a/config/pfsense.php b/config/pfsense.php new file mode 100644 index 000000000..2221e2058 --- /dev/null +++ b/config/pfsense.php @@ -0,0 +1,30 @@ + rtrim(env('PFSENSE_API_BASE_URL', ''), '/'), + + 'api_key' => env('PFSENSE_API_KEY', ''), + + 'username' => env('PFSENSE_API_USERNAME', ''), + + 'password' => env('PFSENSE_API_PASSWORD', ''), + + 'verify_ssl' => filter_var(env('PFSENSE_API_VERIFY_SSL', true), FILTER_VALIDATE_BOOLEAN), + + 'timeout' => (int) env('PFSENSE_API_TIMEOUT', 10), + + 'state_limit' => (int) env('PFSENSE_API_STATE_LIMIT', 1000), +]; + diff --git a/config/queue.php b/config/queue.php new file mode 100644 index 000000000..08b7f7094 --- /dev/null +++ b/config/queue.php @@ -0,0 +1,113 @@ + 'database', + + /* + |-------------------------------------------------------------------------- + | Queue Connections + |-------------------------------------------------------------------------- + | + | Here you may configure the connection options for every queue backend + | used by your application. An example configuration is provided for + | each backend supported by Laravel. You're also free to add more. + | + | Drivers: "sync", "database", "beanstalkd", "sqs", "redis", "null" + | + */ + + 'connections' => [ + + 'sync' => [ + 'driver' => 'sync', + ], + + 'database' => [ + 'driver' => 'database', + 'connection' => env('DB_QUEUE_CONNECTION', null), + 'table' => env('DB_QUEUE_TABLE', 'jobs'), + 'queue' => env('DB_QUEUE', 'default'), + 'retry_after' => env('DB_QUEUE_RETRY_AFTER', 600), + 'after_commit' => false, + ], + + 'beanstalkd' => [ + 'driver' => 'beanstalkd', + 'host' => env('BEANSTALKD_QUEUE_HOST', 'localhost'), + 'queue' => env('BEANSTALKD_QUEUE', 'default'), + 'retry_after' => env('BEANSTALKD_QUEUE_RETRY_AFTER', 90), + 'block_for' => 0, + 'after_commit' => false, + ], + + 'sqs' => [ + 'driver' => 'sqs', + 'key' => env('AWS_ACCESS_KEY_ID'), + 'secret' => env('AWS_SECRET_ACCESS_KEY'), + 'prefix' => env('SQS_PREFIX', 'https://sqs.us-east-1.amazonaws.com/your-account-id'), + 'queue' => env('SQS_QUEUE', 'default'), + 'suffix' => env('SQS_SUFFIX'), + 'region' => env('AWS_DEFAULT_REGION', 'us-east-1'), + 'after_commit' => false, + ], + + 'redis' => [ + 'driver' => 'redis', + 'connection' => env('REDIS_QUEUE_CONNECTION', 'default'), + 'queue' => env('REDIS_QUEUE', 'default'), + 'retry_after' => env('REDIS_QUEUE_RETRY_AFTER', 90), + 'block_for' => null, + 'after_commit' => false, + ], + + ], + + /* + |-------------------------------------------------------------------------- + | Job Batching + |-------------------------------------------------------------------------- + | + | The following options configure the database and table that store job + | batching information. These options can be updated to any database + | connection and table which has been defined by your application. + | + */ + + 'batching' => [ + 'database' => env('DB_CONNECTION', 'sqlite'), + 'table' => 'job_batches', + ], + + /* + |-------------------------------------------------------------------------- + | Failed Queue Jobs + |-------------------------------------------------------------------------- + | + | These options configure the behavior of failed queue job logging so you + | can control how and where failed jobs are stored. Laravel ships with + | support for storing failed jobs in a simple file or in a database. + | + | Supported drivers: "database-uuids", "dynamodb", "file", "null" + | + */ + + 'failed' => [ + 'driver' => env('QUEUE_FAILED_DRIVER', 'database-uuids'), + 'database' => env('DB_CONNECTION', 'sqlite'), + 'table' => 'failed_jobs', + ], + +]; diff --git a/config/scramble.php b/config/scramble.php new file mode 100644 index 000000000..19791039a --- /dev/null +++ b/config/scramble.php @@ -0,0 +1,114 @@ + 'api', + + /* + * Your API domain. By default, app domain is used. This is also a part of the default API routes + * matcher, so when implementing your own, make sure you use this config if needed. + */ + 'api_domain' => null, + + /* + * The path where your OpenAPI specification will be exported. + */ + 'export_path' => 'api.json', + + 'info' => [ + /* + * API version. + */ + 'version' => env('API_VERSION', '1.0.0'), + + /* + * Description rendered on the home page of the API documentation (`/docs/api`). + */ + 'description' => 'API documentation for Paymenter
Requires Paymenter v1.2.0 or higher.', + ], + + /* + * Customize Stoplight Elements UI + */ + 'ui' => [ + /* + * Define the title of the documentation's website. App name is used when this config is `null`. + */ + 'title' => null, + + /* + * Define the theme of the documentation. Available options are `light` and `dark`. + */ + 'theme' => 'light', + + /* + * Hide the `Try It` feature. Enabled by default. + */ + 'hide_try_it' => false, + + /* + * Hide the schemas in the Table of Contents. Enabled by default. + */ + 'hide_schemas' => false, + + /* + * URL to an image that displays as a small square logo next to the title, above the table of contents. + */ + 'logo' => '', + + /* + * Use to fetch the credential policy for the Try It feature. Options are: omit, include (default), and same-origin + */ + 'try_it_credentials_policy' => 'include', + + /* + * There are three layouts for Elements: + * - sidebar - (Elements default) Three-column design with a sidebar that can be resized. + * - responsive - Like sidebar, except at small screen sizes it collapses the sidebar into a drawer that can be toggled open. + * - stacked - Everything in a single column, making integrations with existing websites that have their own sidebar or other columns already. + */ + 'layout' => 'responsive', + ], + + /* + * The list of servers of the API. By default, when `null`, server URL will be created from + * `scramble.api_path` and `scramble.api_domain` config variables. When providing an array, you + * will need to specify the local server URL manually (if needed). + * + * Example of non-default config (final URLs are generated using Laravel `url` helper): + * + * ```php + * 'servers' => [ + * 'Live' => 'api', + * 'Prod' => 'https://scramble.dedoc.co/api', + * ], + * ``` + */ + 'servers' => null, + + /** + * Determines how Scramble stores the descriptions of enum cases. + * Available options: + * - 'description' – Case descriptions are stored as the enum schema's description using table formatting. + * - 'extension' – Case descriptions are stored in the `x-enumDescriptions` enum schema extension. + * + * @see https://redocly.com/docs-legacy/api-reference-docs/specification-extensions/x-enum-descriptions + * - false - Case descriptions are ignored. + */ + 'enum_cases_description_strategy' => 'description', + + 'middleware' => [ + 'web', + RestrictedDocsAccess::class, + ], + + 'extensions' => [ + \App\Helpers\ApiDocumentation\RequestUserExtension::class, + \App\Helpers\ApiDocumentation\AllowedIncludesExtension::class, + ], +]; diff --git a/config/services.php b/config/services.php new file mode 100644 index 000000000..6bb68f6ae --- /dev/null +++ b/config/services.php @@ -0,0 +1,34 @@ + [ + 'token' => env('POSTMARK_TOKEN'), + ], + + 'ses' => [ + 'key' => env('AWS_ACCESS_KEY_ID'), + 'secret' => env('AWS_SECRET_ACCESS_KEY'), + 'region' => env('AWS_DEFAULT_REGION', 'us-east-1'), + ], + + 'slack' => [ + 'notifications' => [ + 'bot_user_oauth_token' => env('SLACK_BOT_USER_OAUTH_TOKEN'), + 'channel' => env('SLACK_BOT_USER_DEFAULT_CHANNEL'), + ], + ], + +]; diff --git a/config/session.php b/config/session.php new file mode 100644 index 000000000..b6e6efad1 --- /dev/null +++ b/config/session.php @@ -0,0 +1,211 @@ + 'database', + + /* + |-------------------------------------------------------------------------- + | Session Lifetime + |-------------------------------------------------------------------------- + | + | Here you may specify the number of minutes that you wish the session + | to be allowed to remain idle before it expires. If you want them + | to expire immediately when the browser is closed then you may + | indicate that via the expire_on_close configuration option. + | + */ + + 'lifetime' => env('SESSION_LIFETIME', 120), + + 'expire_on_close' => env('SESSION_EXPIRE_ON_CLOSE', false), + + /* + |-------------------------------------------------------------------------- + | Session Encryption + |-------------------------------------------------------------------------- + | + | This option allows you to easily specify that all of your session data + | should be encrypted before it's stored. All encryption is performed + | automatically by Laravel and you may use the session like normal. + | + */ + + 'encrypt' => env('SESSION_ENCRYPT', true), + + /* + |-------------------------------------------------------------------------- + | Session File Location + |-------------------------------------------------------------------------- + | + | When utilizing the "file" session driver, we need a spot where session + | files may be stored. A default has been set for you but a different + | location may be specified. This is only needed for file sessions. + | + */ + + 'files' => storage_path('framework/sessions'), + + /* + |-------------------------------------------------------------------------- + | Session Database Connection + |-------------------------------------------------------------------------- + | + | When using the "database" or "redis" session drivers, you may specify a + | connection that should be used to manage these sessions. This should + | correspond to a connection in your database configuration options. + | + */ + + 'connection' => env('SESSION_CONNECTION'), + + /* + |-------------------------------------------------------------------------- + | Session Database Table + |-------------------------------------------------------------------------- + | + | When using the "database" session driver, you may specify the table we + | should use to manage the sessions. Of course, a sensible default is + | provided for you; however, you are free to change this as needed. + | + */ + + 'table' => env('SESSION_TABLE', 'sessions'), + + /* + |-------------------------------------------------------------------------- + | Session Cache Store + |-------------------------------------------------------------------------- + | + | While using one of the framework's cache driven session backends you may + | list a cache store that should be used for these sessions. This value + | must match with one of the application's configured cache "stores". + | + | Affects: "apc", "dynamodb", "memcached", "redis" + | + */ + + 'store' => env('SESSION_STORE'), + + /* + |-------------------------------------------------------------------------- + | Session Sweeping Lottery + |-------------------------------------------------------------------------- + | + | Some session drivers must manually sweep their storage location to get + | rid of old sessions from storage. Here are the chances that it will + | happen on a given request. By default, the odds are 2 out of 100. + | + */ + + 'lottery' => [2, 100], + + /* + |-------------------------------------------------------------------------- + | Session Cookie Name + |-------------------------------------------------------------------------- + | + | Here you may change the name of the cookie used to identify a session + | instance by ID. The name specified here will get used every time a + | new session cookie is created by the framework for every driver. + | + */ + + 'cookie' => 'paymenter_session', + + /* + |-------------------------------------------------------------------------- + | Session Cookie Path + |-------------------------------------------------------------------------- + | + | The session cookie path determines the path for which the cookie will + | be regarded as available. Typically, this will be the root path of + | your application but you are free to change this when necessary. + | + */ + + 'path' => env('SESSION_PATH', '/'), + + /* + |-------------------------------------------------------------------------- + | Session Cookie Domain + |-------------------------------------------------------------------------- + | + | Here you may change the domain of the cookie used to identify a session + | in your application. This will determine which domains the cookie is + | available to in your application. A sensible default has been set. + | + */ + + 'domain' => env('SESSION_DOMAIN'), + + /* + |-------------------------------------------------------------------------- + | HTTPS Only Cookies + |-------------------------------------------------------------------------- + | + | By setting this option to true, session cookies will only be sent back + | to the server if the browser has a HTTPS connection. This will keep + | the cookie from being sent to you when it can't be done securely. + | + */ + + 'secure' => env('SESSION_SECURE_COOKIE'), + + /* + |-------------------------------------------------------------------------- + | HTTP Access Only + |-------------------------------------------------------------------------- + | + | Setting this value to true will prevent JavaScript from accessing the + | value of the cookie and the cookie will only be accessible through + | the HTTP protocol. You are free to modify this option if needed. + | + */ + + 'http_only' => env('SESSION_HTTP_ONLY', true), + + /* + |-------------------------------------------------------------------------- + | Same-Site Cookies + |-------------------------------------------------------------------------- + | + | This option determines how your cookies behave when cross-site requests + | take place, and can be used to mitigate CSRF attacks. By default, we + | will set this value to "lax" since this is a secure default value. + | + | Supported: "lax", "strict", "none", null + | + */ + + 'same_site' => env('SESSION_SAME_SITE', 'lax'), + + /* + |-------------------------------------------------------------------------- + | Partitioned Cookies + |-------------------------------------------------------------------------- + | + | Setting this value to true will tie the cookie to the top-level site for + | a cross-site context. Partitioned cookies are accepted by the browser + | when flagged "secure" and the Same-Site attribute is set to "none". + | + */ + + 'partitioned' => env('SESSION_PARTITIONED_COOKIE', false), + +]; diff --git a/crowdin.yml b/crowdin.yml new file mode 100644 index 000000000..6d9ceca93 --- /dev/null +++ b/crowdin.yml @@ -0,0 +1,5 @@ +files: + - source: /lang/en/*.php + translation: /lang/%two_letters_code%/%original_file_name% + - source: /extensions/Others/Affiliates/resources/lang/en/*.php + translation: /extensions/Others/Affiliates/resources/lang/%two_letters_code%/%original_file_name% diff --git a/database/.gitignore b/database/.gitignore new file mode 100644 index 000000000..9b19b93c9 --- /dev/null +++ b/database/.gitignore @@ -0,0 +1 @@ +*.sqlite* diff --git a/database/factories/UserFactory.php b/database/factories/UserFactory.php new file mode 100644 index 000000000..19f04f34d --- /dev/null +++ b/database/factories/UserFactory.php @@ -0,0 +1,45 @@ + + */ +class UserFactory extends Factory +{ + /** + * The current password being used by the factory. + */ + protected static ?string $password; + + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'first_name' => fake()->name(), + 'last_name' => fake()->name(), + 'email' => fake()->unique()->safeEmail(), + 'email_verified_at' => now(), + 'password' => static::$password ??= Hash::make('password'), + 'remember_token' => Str::random(10), + ]; + } + + /** + * Indicate that the model's email address should be unverified. + */ + public function unverified(): static + { + return $this->state(fn (array $attributes) => [ + 'email_verified_at' => null, + ]); + } +} diff --git a/database/migrations/0001_01_01_000002_create_jobs_table.php b/database/migrations/0001_01_01_000002_create_jobs_table.php new file mode 100644 index 000000000..425e7058f --- /dev/null +++ b/database/migrations/0001_01_01_000002_create_jobs_table.php @@ -0,0 +1,57 @@ +id(); + $table->string('queue')->index(); + $table->longText('payload'); + $table->unsignedTinyInteger('attempts'); + $table->unsignedInteger('reserved_at')->nullable(); + $table->unsignedInteger('available_at'); + $table->unsignedInteger('created_at'); + }); + + Schema::create('job_batches', function (Blueprint $table) { + $table->string('id')->primary(); + $table->string('name'); + $table->integer('total_jobs'); + $table->integer('pending_jobs'); + $table->integer('failed_jobs'); + $table->longText('failed_job_ids'); + $table->mediumText('options')->nullable(); + $table->integer('cancelled_at')->nullable(); + $table->integer('created_at'); + $table->integer('finished_at')->nullable(); + }); + + Schema::create('failed_jobs', function (Blueprint $table) { + $table->id(); + $table->string('uuid')->unique(); + $table->text('connection'); + $table->text('queue'); + $table->longText('payload'); + $table->longText('exception'); + $table->timestamp('failed_at')->useCurrent(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('jobs'); + Schema::dropIfExists('job_batches'); + Schema::dropIfExists('failed_jobs'); + } +}; diff --git a/database/migrations/2024_02_15_122223_create_roles_table.php b/database/migrations/2024_02_15_122223_create_roles_table.php new file mode 100644 index 000000000..351924194 --- /dev/null +++ b/database/migrations/2024_02_15_122223_create_roles_table.php @@ -0,0 +1,29 @@ +id(); + $table->string('name')->unique(); + $table->json('permissions')->nullable(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('roles'); + } +}; diff --git a/database/migrations/2024_02_15_122224_create_users_table.php b/database/migrations/2024_02_15_122224_create_users_table.php new file mode 100644 index 000000000..d017d6c0a --- /dev/null +++ b/database/migrations/2024_02_15_122224_create_users_table.php @@ -0,0 +1,52 @@ +id(); + $table->string('first_name')->nullable(); + $table->string('last_name')->nullable(); + $table->string('email')->unique(); + $table->foreignId('role_id')->index()->nullable(); + $table->timestamp('email_verified_at')->nullable(); + $table->string('password'); + $table->text('tfa_secret')->nullable(); + $table->rememberToken(); + $table->timestamps(); + }); + + Schema::create('password_reset_tokens', function (Blueprint $table) { + $table->string('email')->primary(); + $table->string('token'); + $table->timestamp('created_at')->nullable(); + }); + + Schema::create('sessions', function (Blueprint $table) { + $table->string('id')->primary(); + $table->foreignId('user_id')->nullable()->index(); + $table->string('ip_address', 45)->nullable(); + $table->text('user_agent')->nullable(); + $table->longText('payload'); + $table->integer('last_activity')->index(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('users'); + Schema::dropIfExists('password_reset_tokens'); + Schema::dropIfExists('sessions'); + } +}; diff --git a/database/migrations/2024_02_15_122225_create_settings_table.php b/database/migrations/2024_02_15_122225_create_settings_table.php new file mode 100644 index 000000000..50ed34d53 --- /dev/null +++ b/database/migrations/2024_02_15_122225_create_settings_table.php @@ -0,0 +1,33 @@ +id(); + $table->text('key'); + $table->text('value')->nullable(); + $table->string('type')->default('string'); + $table->boolean('encrypted')->default(false); + $table->nullableMorphs('settingable'); + $table->unique(['key', 'settingable_id', 'settingable_type']); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('settings'); + } +}; diff --git a/database/migrations/2024_02_15_122227_create_extensions_table.php b/database/migrations/2024_02_15_122227_create_extensions_table.php new file mode 100644 index 000000000..3efb8d537 --- /dev/null +++ b/database/migrations/2024_02_15_122227_create_extensions_table.php @@ -0,0 +1,31 @@ +id(); + $table->string('name'); + $table->string('extension'); + $table->string('type'); + $table->boolean('enabled')->default(false); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('extensions'); + } +}; diff --git a/database/migrations/2024_02_15_122228_create_categories_table.php b/database/migrations/2024_02_15_122228_create_categories_table.php new file mode 100644 index 000000000..eb942e046 --- /dev/null +++ b/database/migrations/2024_02_15_122228_create_categories_table.php @@ -0,0 +1,34 @@ +id(); + $table->string('slug')->unique(); + $table->string('name'); + $table->text('description')->nullable(); + $table->string('image')->nullable(); + $table->foreignId('parent_id')->nullable()->constrained('categories'); + $table->text('full_slug')->nullable(); + $table->unsignedTinyInteger('sort')->nullable(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('categories'); + } +}; diff --git a/database/migrations/2024_02_15_122231_create_products_table.php b/database/migrations/2024_02_15_122231_create_products_table.php new file mode 100644 index 000000000..e7a1b1638 --- /dev/null +++ b/database/migrations/2024_02_15_122231_create_products_table.php @@ -0,0 +1,38 @@ +id(); + $table->foreignId('category_id')->constrained(); + $table->string('name'); + $table->string('image')->nullable(); + $table->string('slug')->nullable(); + $table->text('description')->nullable(); + $table->integer('stock')->nullable(); + $table->integer('per_user_limit')->nullable(); + $table->unsignedTinyInteger('sort')->nullable(); + $table->enum('allow_quantity', ['disabled', 'separated', 'combined'])->default('disabled'); + $table->foreignIdFor(\App\Models\Server::class, 'server_id')->nullable(); + $table->text('email_template')->nullable(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('products'); + } +}; diff --git a/database/migrations/2024_02_15_122232_create_currencies_table.php b/database/migrations/2024_02_15_122232_create_currencies_table.php new file mode 100644 index 000000000..e2d25e6bd --- /dev/null +++ b/database/migrations/2024_02_15_122232_create_currencies_table.php @@ -0,0 +1,29 @@ +string('code', 3)->primary(); + $table->string('prefix')->nullable(); + $table->string('suffix')->nullable(); + $table->enum('format', ['1.000,00', '1,000.00', '1 000,00', '1 000.00'])->default('1.000,00'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('currencies'); + } +}; diff --git a/database/migrations/2024_02_15_122233_create_plans_table.php b/database/migrations/2024_02_15_122233_create_plans_table.php new file mode 100644 index 000000000..79f7dbbe3 --- /dev/null +++ b/database/migrations/2024_02_15_122233_create_plans_table.php @@ -0,0 +1,32 @@ +id(); + $table->string('name')->nullable(); + $table->morphs('priceable'); + $table->enum('type', ['free', 'one-time', 'recurring']); + $table->integer('billing_period')->nullable(); + $table->enum('billing_unit', ['hour', 'day', 'week', 'month', 'year'])->nullable(); + $table->unsignedTinyInteger('sort')->nullable(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('plans'); + } +}; diff --git a/database/migrations/2024_02_15_122235_create_prices_table.php b/database/migrations/2024_02_15_122235_create_prices_table.php new file mode 100644 index 000000000..be6c7cca5 --- /dev/null +++ b/database/migrations/2024_02_15_122235_create_prices_table.php @@ -0,0 +1,30 @@ +id(); + $table->decimal('price', 17, 2)->nullable(); + $table->decimal('setup_fee', 17, 2)->nullable(); + $table->string('currency_code', 3); + $table->foreignIdFor(\App\Models\Plan::class)->nullable(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('prices'); + } +}; diff --git a/database/migrations/2024_04_20_111206_create_audit_logs_table.php b/database/migrations/2024_04_20_111206_create_audit_logs_table.php new file mode 100644 index 000000000..35fed83d3 --- /dev/null +++ b/database/migrations/2024_04_20_111206_create_audit_logs_table.php @@ -0,0 +1,34 @@ +id(); + $table->string('action'); + $table->string('description')->nullable(); + $table->numericMorphs('model'); + $table->foreignIdFor(\App\Models\User::class)->nullable(); + $table->json('changes')->nullable(); + $table->string('ip_address')->nullable(); + $table->string('user_agent')->nullable(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('audit_logs'); + } +}; diff --git a/database/migrations/2024_06_19_143154_create_config_options_table.php b/database/migrations/2024_06_19_143154_create_config_options_table.php new file mode 100644 index 000000000..d5eeebf50 --- /dev/null +++ b/database/migrations/2024_06_19_143154_create_config_options_table.php @@ -0,0 +1,35 @@ +id(); + $table->string('name'); + $table->string('env_variable')->nullable(); + $table->string('type')->nullable(); + $table->unsignedTinyInteger('sort')->nullable(); + $table->boolean('hidden')->default(false); + $table->timestamps(); + }); + Schema::table('config_options', function (Blueprint $table) { + $table->foreignIdFor(\App\Models\ConfigOption::class, 'parent_id')->nullable()->constrained('config_options')->after('hidden')->cascadeOnDelete(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('config_options'); + } +}; diff --git a/database/migrations/2024_06_27_122914_create_config_option_products_table.php b/database/migrations/2024_06_27_122914_create_config_option_products_table.php new file mode 100644 index 000000000..a67b698a7 --- /dev/null +++ b/database/migrations/2024_06_27_122914_create_config_option_products_table.php @@ -0,0 +1,28 @@ +id(); + $table->foreignIdFor(\App\Models\ConfigOption::class, 'config_option_id')->constrained()->cascadeOnDelete(); + $table->foreignIdFor(\App\Models\Product::class, 'product_id')->constrained()->cascadeOnDelete(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('config_option_products'); + } +}; diff --git a/database/migrations/2024_07_02_190049_create_tax_rates_table.php b/database/migrations/2024_07_02_190049_create_tax_rates_table.php new file mode 100644 index 000000000..4c99e4eef --- /dev/null +++ b/database/migrations/2024_07_02_190049_create_tax_rates_table.php @@ -0,0 +1,30 @@ +id(); + $table->string('name'); + $table->decimal('rate', 5, 2); + $table->string('country')->unique(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('tax_rates'); + } +}; diff --git a/database/migrations/2024_07_05_142906_create_coupons_table.php b/database/migrations/2024_07_05_142906_create_coupons_table.php new file mode 100644 index 000000000..ee7542ad9 --- /dev/null +++ b/database/migrations/2024_07_05_142906_create_coupons_table.php @@ -0,0 +1,34 @@ +id(); + $table->string('type'); + $table->integer('recurring')->nullable(); + $table->string('code'); + $table->decimal('value', 17, 2)->nullable(); + $table->integer('max_uses')->nullable(); + $table->dateTime('starts_at')->nullable(); + $table->dateTime('expires_at')->nullable(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('coupons'); + } +}; diff --git a/database/migrations/2024_07_05_143848_create_orders_table.php b/database/migrations/2024_07_05_143848_create_orders_table.php new file mode 100644 index 000000000..fd1dc72ad --- /dev/null +++ b/database/migrations/2024_07_05_143848_create_orders_table.php @@ -0,0 +1,29 @@ +id(); + $table->foreignIdFor(\App\Models\User::class)->constrained()->cascadeOnDelete(); + $table->string('currency_code', 3); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('orders'); + } +}; diff --git a/database/migrations/2024_07_05_143851_create_services_table.php b/database/migrations/2024_07_05_143851_create_services_table.php new file mode 100644 index 000000000..e7e266943 --- /dev/null +++ b/database/migrations/2024_07_05_143851_create_services_table.php @@ -0,0 +1,38 @@ +id(); + $table->string('status')->default('pending'); + $table->foreignIdFor(\App\Models\Order::class)->nullable()->constrained()->cascadeOnDelete(); + $table->foreignIdFor(\App\Models\Product::class)->nullable()->constrained()->nullOnDelete(); + $table->foreignIdFor(\App\Models\User::class)->constrained()->cascadeOnDelete(); + $table->string('currency_code', 3); + $table->integer('quantity')->default(1); + $table->decimal('price', 17, 2); + $table->foreignIdFor(\App\Models\Plan::class)->nullable()->constrained()->nullOnDelete(); + $table->foreignIdFor(\App\Models\Coupon::class)->nullable()->constrained()->nullOnDelete(); + $table->dateTime('expires_at')->nullable(); + $table->string('subscription_id')->nullable(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('services'); + } +}; diff --git a/database/migrations/2024_07_05_143855_create_service_configs_table.php b/database/migrations/2024_07_05_143855_create_service_configs_table.php new file mode 100644 index 000000000..71f83e8fa --- /dev/null +++ b/database/migrations/2024_07_05_143855_create_service_configs_table.php @@ -0,0 +1,30 @@ +id(); + $table->morphs('configurable'); + $table->foreignIdFor(\App\Models\ConfigOption::class)->constrained()->cascadeOnDelete(); + $table->foreignId('config_value_id')->constrained('config_options'); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('service_configs'); + } +}; diff --git a/database/migrations/2024_07_05_144020_create_invoices_table.php b/database/migrations/2024_07_05_144020_create_invoices_table.php new file mode 100644 index 000000000..931f40e4c --- /dev/null +++ b/database/migrations/2024_07_05_144020_create_invoices_table.php @@ -0,0 +1,31 @@ +id(); + $table->string('status')->default('pending'); + $table->date('due_at')->nullable(); + $table->string('currency_code', 3); + $table->foreignIdFor(\App\Models\User::class)->constrained()->cascadeOnDelete(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('invoices'); + } +}; diff --git a/database/migrations/2024_07_05_144024_create_invoice_items_table.php b/database/migrations/2024_07_05_144024_create_invoice_items_table.php new file mode 100644 index 000000000..beff5c108 --- /dev/null +++ b/database/migrations/2024_07_05_144024_create_invoice_items_table.php @@ -0,0 +1,32 @@ +id(); + $table->foreignIdFor(\App\Models\Invoice::class)->constrained()->cascadeOnDelete(); + $table->decimal('price', 17, 2); + $table->integer('quantity')->default(1); + $table->string('description')->nullable(); + $table->nullableMorphs('reference'); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('invoice_items'); + } +}; diff --git a/database/migrations/2024_07_10_203119_create_invoice_transactions_table.php b/database/migrations/2024_07_10_203119_create_invoice_transactions_table.php new file mode 100644 index 000000000..f73e74f53 --- /dev/null +++ b/database/migrations/2024_07_10_203119_create_invoice_transactions_table.php @@ -0,0 +1,33 @@ +id(); + $table->foreignIdFor(App\Models\Invoice::class)->constrained()->cascadeOnDelete(); + $table->unsignedBigInteger('gateway_id')->nullable(); + $table->foreign('gateway_id')->references('id')->on('extensions')->cascadeOnDelete(); + $table->decimal('amount', 17, 2); + $table->decimal('fee', 17, 2)->nullable(); + $table->string('transaction_id')->nullable(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('invoice_transactions'); + } +}; diff --git a/database/migrations/2024_07_13_121420_create_coupon_products_table.php b/database/migrations/2024_07_13_121420_create_coupon_products_table.php new file mode 100644 index 000000000..5aff08402 --- /dev/null +++ b/database/migrations/2024_07_13_121420_create_coupon_products_table.php @@ -0,0 +1,28 @@ +id(); + $table->foreignIdFor(\App\Models\Coupon::class)->constrained()->cascadeOnDelete(); + $table->foreignIdFor(\App\Models\Product::class)->constrained()->cascadeOnDelete(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('coupon_products'); + } +}; diff --git a/database/migrations/2024_07_15_134223_create_custom_properties_table.php b/database/migrations/2024_07_15_134223_create_custom_properties_table.php new file mode 100644 index 000000000..4ca229df3 --- /dev/null +++ b/database/migrations/2024_07_15_134223_create_custom_properties_table.php @@ -0,0 +1,36 @@ +id(); + $table->string('name'); + $table->string('description')->nullable(); + $table->string('key')->unique(); + $table->string('type'); + $table->string('model'); + $table->string('validation')->nullable(); + $table->json('allowed_values')->nullable(); + $table->boolean('non_editable'); + $table->boolean('required'); + $table->boolean('show_on_invoice'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('custom_properties'); + } +}; diff --git a/database/migrations/2024_07_15_134515_create_properties_table.php b/database/migrations/2024_07_15_134515_create_properties_table.php new file mode 100644 index 000000000..c406c4457 --- /dev/null +++ b/database/migrations/2024_07_15_134515_create_properties_table.php @@ -0,0 +1,34 @@ +id(); + $table->foreignIdFor(CustomProperty::class)->nullable()->constrained()->cascadeOnDelete(); + $table->string('name')->nullable(); + $table->string('key'); + $table->unique(['key', 'model_id', 'model_type']); + $table->text('value'); + $table->morphs('model'); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('properties'); + } +}; diff --git a/database/migrations/2024_08_09_120929_create_tickets_table.php b/database/migrations/2024_08_09_120929_create_tickets_table.php new file mode 100644 index 000000000..c65818911 --- /dev/null +++ b/database/migrations/2024_08_09_120929_create_tickets_table.php @@ -0,0 +1,34 @@ +id(); + $table->string('subject'); + $table->string('status')->default('open'); + $table->string('priority')->default('normal'); + $table->string('department')->nullable(); + $table->foreignIdFor(\App\Models\User::class)->constrained()->cascadeOnDelete(); + $table->foreignIdFor(\App\Models\User::class, 'assigned_to')->nullable()->constrained('users')->nullOnDelete(); + $table->foreignIdFor(\App\Models\Service::class)->nullable()->constrained()->nullOnDelete(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('tickets'); + } +}; diff --git a/database/migrations/2024_08_09_120938_create_ticket_messages_table.php b/database/migrations/2024_08_09_120938_create_ticket_messages_table.php new file mode 100644 index 000000000..db0537b68 --- /dev/null +++ b/database/migrations/2024_08_09_120938_create_ticket_messages_table.php @@ -0,0 +1,30 @@ +id(); + $table->foreignIdFor(\App\Models\Ticket::class)->constrained()->cascadeOnDelete(); + $table->foreignIdFor(\App\Models\User::class)->constrained()->cascadeOnDelete(); + $table->text('message'); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('ticket_messages'); + } +}; diff --git a/database/migrations/2024_08_13_185122_create_email_templates_table.php b/database/migrations/2024_08_13_185122_create_email_templates_table.php new file mode 100644 index 000000000..cc0887e57 --- /dev/null +++ b/database/migrations/2024_08_13_185122_create_email_templates_table.php @@ -0,0 +1,33 @@ +id(); + $table->string('key')->unique(); + $table->string('subject'); + $table->boolean('enabled')->default(true); + $table->text('body'); + $table->json('cc')->nullable(); + $table->json('bcc')->nullable(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('email_templates'); + } +}; diff --git a/database/migrations/2024_08_22_094747_create_email_logs_table.php b/database/migrations/2024_08_22_094747_create_email_logs_table.php new file mode 100644 index 000000000..db12ac3c0 --- /dev/null +++ b/database/migrations/2024_08_22_094747_create_email_logs_table.php @@ -0,0 +1,35 @@ +id(); + $table->foreignIdFor(\App\Models\User::class)->nullable()->constrained()->nullOnDelete(); + $table->string('subject'); + $table->string('to'); + $table->longText('body'); + $table->timestamp('sent_at')->nullable(); + $table->string('status')->default('pending'); + $table->text('error')->nullable(); + $table->string('job_uuid')->nullable(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('email_logs'); + } +}; diff --git a/database/migrations/2024_09_13_193832_create_oauth_auth_codes_table.php b/database/migrations/2024_09_13_193832_create_oauth_auth_codes_table.php new file mode 100644 index 000000000..247a16794 --- /dev/null +++ b/database/migrations/2024_09_13_193832_create_oauth_auth_codes_table.php @@ -0,0 +1,31 @@ +string('id', 100)->primary(); + $table->unsignedBigInteger('user_id')->index(); + $table->uuid('client_id'); + $table->text('scopes')->nullable(); + $table->boolean('revoked'); + $table->dateTime('expires_at')->nullable(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('oauth_auth_codes'); + } +}; diff --git a/database/migrations/2024_09_13_193833_create_oauth_access_tokens_table.php b/database/migrations/2024_09_13_193833_create_oauth_access_tokens_table.php new file mode 100644 index 000000000..eef9c339e --- /dev/null +++ b/database/migrations/2024_09_13_193833_create_oauth_access_tokens_table.php @@ -0,0 +1,33 @@ +string('id', 100)->primary(); + $table->unsignedBigInteger('user_id')->nullable()->index(); + $table->uuid('client_id'); + $table->string('name')->nullable(); + $table->text('scopes')->nullable(); + $table->boolean('revoked'); + $table->timestamps(); + $table->dateTime('expires_at')->nullable(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('oauth_access_tokens'); + } +}; diff --git a/database/migrations/2024_09_13_193834_create_oauth_refresh_tokens_table.php b/database/migrations/2024_09_13_193834_create_oauth_refresh_tokens_table.php new file mode 100644 index 000000000..b007904ce --- /dev/null +++ b/database/migrations/2024_09_13_193834_create_oauth_refresh_tokens_table.php @@ -0,0 +1,29 @@ +string('id', 100)->primary(); + $table->string('access_token_id', 100)->index(); + $table->boolean('revoked'); + $table->dateTime('expires_at')->nullable(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('oauth_refresh_tokens'); + } +}; diff --git a/database/migrations/2024_09_13_193835_create_oauth_clients_table.php b/database/migrations/2024_09_13_193835_create_oauth_clients_table.php new file mode 100644 index 000000000..8e437ea30 --- /dev/null +++ b/database/migrations/2024_09_13_193835_create_oauth_clients_table.php @@ -0,0 +1,35 @@ +uuid('id')->primary(); + $table->unsignedBigInteger('user_id')->nullable()->index(); + $table->string('name'); + $table->string('secret', 100)->nullable(); + $table->string('provider')->nullable(); + $table->text('redirect'); + $table->boolean('personal_access_client'); + $table->boolean('password_client'); + $table->boolean('revoked'); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('oauth_clients'); + } +}; diff --git a/database/migrations/2024_09_13_193836_create_oauth_personal_access_clients_table.php b/database/migrations/2024_09_13_193836_create_oauth_personal_access_clients_table.php new file mode 100644 index 000000000..15398c96f --- /dev/null +++ b/database/migrations/2024_09_13_193836_create_oauth_personal_access_clients_table.php @@ -0,0 +1,28 @@ +bigIncrements('id'); + $table->uuid('client_id'); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('oauth_personal_access_clients'); + } +}; diff --git a/database/migrations/2024_09_16_171549_create_service_cancellations_table.php b/database/migrations/2024_09_16_171549_create_service_cancellations_table.php new file mode 100644 index 000000000..3c60adc73 --- /dev/null +++ b/database/migrations/2024_09_16_171549_create_service_cancellations_table.php @@ -0,0 +1,30 @@ +id(); + $table->foreignIdFor(\App\Models\Service::class)->constrained()->cascadeOnDelete(); + $table->string('reason')->nullable(); + $table->enum('type', ['immediate', 'end_of_period'])->default('immediate'); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('service_cancellations'); + } +}; diff --git a/database/migrations/2024_09_16_171555_create_service_upgrades_table.php b/database/migrations/2024_09_16_171555_create_service_upgrades_table.php new file mode 100644 index 000000000..e0b770a6f --- /dev/null +++ b/database/migrations/2024_09_16_171555_create_service_upgrades_table.php @@ -0,0 +1,33 @@ +id(); + $table->foreignIdFor(\App\Models\Service::class)->constrained()->cascadeOnDelete(); + $table->foreignIdFor(\App\Models\Plan::class)->constrained()->cascadeOnDelete(); + $table->foreignIdFor(\App\Models\Product::class)->constrained()->cascadeOnDelete(); + $table->foreignIdFor(\App\Models\Invoice::class)->constrained()->cascadeOnDelete(); + $table->string('status')->default('pending'); + $table->string('type')->default('product'); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('service_upgrades'); + } +}; diff --git a/database/migrations/2024_09_17_111932_create_product_upgrades_table.php b/database/migrations/2024_09_17_111932_create_product_upgrades_table.php new file mode 100644 index 000000000..7638a643e --- /dev/null +++ b/database/migrations/2024_09_17_111932_create_product_upgrades_table.php @@ -0,0 +1,29 @@ +id(); + $table->foreignIdFor(\App\Models\Product::class)->constrained()->onDelete('cascade'); + $table->foreignIdFor(\App\Models\Product::class, 'upgrade_id')->constrained('products')->onDelete('cascade'); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('product_upgrades'); + } +}; diff --git a/database/migrations/2024_12_26_185218_create_credits_table.php b/database/migrations/2024_12_26_185218_create_credits_table.php new file mode 100644 index 000000000..aa1a21e70 --- /dev/null +++ b/database/migrations/2024_12_26_185218_create_credits_table.php @@ -0,0 +1,30 @@ +id(); + $table->foreignIdFor(App\Models\User::class)->constrained()->cascadeOnDelete(); + $table->string('currency_code', 3); + $table->decimal('amount', 17, 2); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('credits'); + } +}; diff --git a/database/migrations/2025_02_24_052318_make_invoice_nullable_in_service_upgrades.php b/database/migrations/2025_02_24_052318_make_invoice_nullable_in_service_upgrades.php new file mode 100644 index 000000000..6547d5f11 --- /dev/null +++ b/database/migrations/2025_02_24_052318_make_invoice_nullable_in_service_upgrades.php @@ -0,0 +1,28 @@ +foreignId('invoice_id')->nullable()->change(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('service_upgrades', function (Blueprint $table) { + $table->foreignId('invoice_id')->nullable()->change(); + }); + } +}; diff --git a/database/migrations/2025_03_07_190949_create_debug_logs_table.php b/database/migrations/2025_03_07_190949_create_debug_logs_table.php new file mode 100644 index 000000000..b80ceb6eb --- /dev/null +++ b/database/migrations/2025_03_07_190949_create_debug_logs_table.php @@ -0,0 +1,29 @@ +id(); + $table->string('type'); + $table->json('context'); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('debug_logs'); + } +}; diff --git a/database/migrations/2025_03_11_205629_add_hidden_to_products.php b/database/migrations/2025_03_11_205629_add_hidden_to_products.php new file mode 100644 index 000000000..8ee1bda1d --- /dev/null +++ b/database/migrations/2025_03_11_205629_add_hidden_to_products.php @@ -0,0 +1,28 @@ +boolean('hidden')->default(false); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('products', function (Blueprint $table) { + $table->dropColumn('hidden'); + }); + } +}; diff --git a/database/migrations/2025_04_25_064729_add_number_to_invoices.php b/database/migrations/2025_04_25_064729_add_number_to_invoices.php new file mode 100644 index 000000000..ed0782c16 --- /dev/null +++ b/database/migrations/2025_04_25_064729_add_number_to_invoices.php @@ -0,0 +1,43 @@ +string('number')->nullable()->unique()->after('id'); + }); + + DB::statement('UPDATE invoices SET number = id'); + + Schema::table('invoices', function (Blueprint $table) { + $table->string('number')->nullable(false)->change(); + }); + + // Set the default value for invoice_number to the current max value + Setting::updateOrCreate([ + 'key' => 'invoice_number', + ], [ + 'value' => DB::table('invoices')->max('id') ?: 0, + ]); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('invoices', function (Blueprint $table) { + $table->dropColumn('number'); + }); + } +}; diff --git a/database/migrations/2025_05_22_073500_add_max_uses_per_user_to_coupons_table.php b/database/migrations/2025_05_22_073500_add_max_uses_per_user_to_coupons_table.php new file mode 100644 index 000000000..f23bcd169 --- /dev/null +++ b/database/migrations/2025_05_22_073500_add_max_uses_per_user_to_coupons_table.php @@ -0,0 +1,28 @@ +integer('max_uses_per_user')->nullable()->after('max_uses'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('coupons', function (Blueprint $table) { + $table->dropColumn('max_uses_per_user'); + }); + } +}; diff --git a/database/migrations/2025_06_04_132119_create_api_keys_table.php b/database/migrations/2025_06_04_132119_create_api_keys_table.php new file mode 100644 index 000000000..8ccd114bf --- /dev/null +++ b/database/migrations/2025_06_04_132119_create_api_keys_table.php @@ -0,0 +1,40 @@ +id(); + $table->string('name'); + $table->json('permissions')->nullable(); + $table->string('token')->unique(); + $table->foreignIdFor(User::class, 'user_id') + ->nullable() + ->constrained() + ->cascadeOnDelete() + ->cascadeOnUpdate(); + $table->string('type')->nullable(); + $table->json('ip_addresses')->nullable(); + $table->dateTime('last_used_at')->nullable(); + $table->boolean('enabled')->default(true); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('api_keys'); + } +}; diff --git a/database/migrations/2025_06_20_101701_add_upgradable_to_config_options.php b/database/migrations/2025_06_20_101701_add_upgradable_to_config_options.php new file mode 100644 index 000000000..7d022c390 --- /dev/null +++ b/database/migrations/2025_06_20_101701_add_upgradable_to_config_options.php @@ -0,0 +1,28 @@ +boolean('upgradable')->default(false)->after('hidden'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('config_options', function (Blueprint $table) { + $table->dropColumn('upgradable'); + }); + } +}; diff --git a/database/migrations/2025_06_30_092448_cascade_config_value_id_on_service_configs.php b/database/migrations/2025_06_30_092448_cascade_config_value_id_on_service_configs.php new file mode 100644 index 000000000..d128c86c4 --- /dev/null +++ b/database/migrations/2025_06_30_092448_cascade_config_value_id_on_service_configs.php @@ -0,0 +1,35 @@ +dropForeign(['config_value_id']); + $table->foreign('config_value_id') + ->references('id') + ->on('config_options') + ->cascadeOnDelete(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('service_configs', function (Blueprint $table) { + $table->dropForeign(['config_value_id']); + $table->foreign('config_value_id') + ->references('id') + ->on('config_options'); + }); + } +}; diff --git a/database/seeders/CustomPropertySeeder.php b/database/seeders/CustomPropertySeeder.php new file mode 100644 index 000000000..e7b619a16 --- /dev/null +++ b/database/seeders/CustomPropertySeeder.php @@ -0,0 +1,104 @@ +insertOrIgnore([ + [ + 'key' => 'phone', + 'name' => 'Phone', + 'model' => 'App\Models\User', + 'type' => 'string', + 'non_editable' => 0, + 'required' => 1, + 'show_on_invoice' => 0, + 'validation' => 'string|max:255', + ], + [ + 'key' => 'company_name', + 'name' => 'Company Name', + 'model' => 'App\Models\User', + 'type' => 'string', + 'non_editable' => 0, + 'required' => 0, + 'show_on_invoice' => 1, + 'validation' => 'string|max:255', + ], + [ + 'key' => 'address', + 'name' => 'Address', + 'model' => 'App\Models\User', + 'type' => 'string', + 'non_editable' => 0, + 'required' => 1, + 'show_on_invoice' => 1, + 'validation' => 'string|max:255', + ], + [ + 'key' => 'address2', + 'name' => 'Address 2', + 'model' => 'App\Models\User', + 'type' => 'string', + 'non_editable' => 0, + 'required' => 0, + 'show_on_invoice' => 0, + 'validation' => 'string|max:255', + ], + [ + 'key' => 'city', + 'name' => 'City', + 'model' => 'App\Models\User', + 'type' => 'string', + 'non_editable' => 0, + 'required' => 1, + 'show_on_invoice' => 1, + 'validation' => 'string|max:255', + ], + [ + 'key' => 'state', + 'name' => 'State', + 'model' => 'App\Models\User', + 'type' => 'string', + 'non_editable' => 0, + 'required' => 1, + 'show_on_invoice' => 1, + 'validation' => 'string|max:255', + ], + [ + 'key' => 'zip', + 'name' => 'ZIP', + 'model' => 'App\Models\User', + 'type' => 'string', + 'non_editable' => 0, + 'required' => 1, + 'show_on_invoice' => 1, + 'validation' => 'string|max:255', + ], + ]); + + DB::table('custom_properties')->insertOrIgnore([ + [ + 'key' => 'country', + 'name' => 'Country', + 'model' => 'App\Models\User', + 'type' => 'select', + 'non_editable' => 0, + 'required' => 1, + 'show_on_invoice' => 1, + 'allowed_values' => json_encode(array_values(array_slice(config('app.countries', []), 1))), + 'validation' => 'string|max:255', + ], + ]); + } +} diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php new file mode 100644 index 000000000..39045ec61 --- /dev/null +++ b/database/seeders/DatabaseSeeder.php @@ -0,0 +1,54 @@ +create(); + Role::updateOrCreate(['name' => 'admin'], ['permissions' => ['*']]); + + foreach (\App\Classes\Settings::settings() as $settings) { + foreach ($settings as $setting) { + if (!isset($setting['default'])) { + continue; + } + if (in_array($setting['name'], ['mail_header', 'mail_footer', 'mail_css'])) { + // Read from file in ./data/ + $setting['default'] = file_get_contents(__DIR__ . '/data/' . $setting['name']); + } + Setting::firstOrCreate([ + 'key' => $setting['name'], + ], [ + 'value' => $setting['default'], + 'type' => $setting['database_type'] ?? 'string', + ]); + } + } + + // Seed default currency (USD) + if (\App\Models\Currency::count() === 0) { + \App\Models\Currency::create([ + 'code' => 'USD', + 'prefix' => '$', + 'suffix' => '', + 'format' => '1.000,00', + ]); + } + + \App\Providers\SettingsProvider::flushCache(); + + $this->call([ + CustomPropertySeeder::class, + EmailTemplateSeeder::class, + ]); + } +} diff --git a/database/seeders/EmailTemplateSeeder.php b/database/seeders/EmailTemplateSeeder.php new file mode 100644 index 000000000..ed327b7a7 --- /dev/null +++ b/database/seeders/EmailTemplateSeeder.php @@ -0,0 +1,214 @@ +insertOrIgnore([ + [ + 'key' => 'new_login_detected', + 'subject' => 'New login detected', + 'body' => <<<'HTML' + # New login detected + + A new login was detected on your account. + + - IP: {{ $ip }} + - Device: {{ $device }} + - Time: {{ $time }} + + **If this was you** + You can ignore this message, there is no need to take any action. + + **If this wasn't you** + Please reset your password [here]({{ route('password.request') }}). + HTML, + ], + [ + 'key' => 'new_invoice_created', + 'subject' => 'New invoice created', + 'body' => <<<'HTML' + # New invoice created + + A new invoice was created on your account. + + Total amount: **{{ $total }}** + + +
+ + | Item | Quantity | Price | + | :------: | :------: | :------: | + @foreach ($items as $item) + | {{ $item->description }} | {{ $item->quantity }} | {{ $item->price }} | + @endforeach +
+ + + + @if($has_subscription) + You have a active subscription, the invoice will be automatically paid. + @endif + HTML, + ], + [ + 'key' => 'new_order_created', + 'subject' => 'New order created', + 'body' => <<<'HTML' + # New order created + + A new order was created on your account. + + **Order details** +
+ + | Item | Quantity | Price | + | :------: | :------: | :------: | + @foreach ($items as $item) + | {{ $item->product->name }} | {{ $item->quantity }} | {{ $item->formattedPrice }} | + @endforeach +
+ HTML, + ], + [ + 'key' => 'new_server_created', + 'subject' => 'Server activated', + 'body' => <<<'HTML' + # Server activated + + Your server has been activated. + + **Server details** + - Name: {{ $service->product->name }} + + @isset($service->product->email_template) + **Server information** + {!! Str::markdown(Illuminate\View\Compilers\BladeCompiler::render($service->product->email_template, get_defined_vars()['__data'])) !!} + @endisset + HTML, + ], + [ + 'key' => 'server_suspended', + 'subject' => 'Server suspended', + 'body' => <<<'HTML' + # Server suspended + + Your server has been suspended due to a payment failure. + + **Server details** + - Name: {{ $service->product->name }} + + Please pay the invoice to reactivate the server. + HTML, + ], + [ + 'key' => 'server_terminated', + 'subject' => 'Server terminated', + 'body' => <<<'HTML' + # Server terminated + + Your server has been terminated. + + **Server details** + - Name: {{ $service->product->name }} + + Do you consider it a mistake? + + HTML, + ], + [ + 'key' => 'new_ticket_message', + 'subject' => 'New ticket reply', + 'body' => <<<'HTML' + # New ticket reply + + {{ $ticketMessage->user->name }} replied to your ticket. + + **Message** + {!! Str::markdown($ticketMessage->message, [ + 'html_input' => 'strip', + 'allow_unsafe_links' => false, + ]) !!} + HTML, + ], + [ + 'key' => 'email_verification', + 'subject' => 'Email verification', + 'body' => <<<'HTML' + # Email verification + + Please verify your email address by clicking the link below. + + + This link will expire in 60 minutes. + + If you did not create an account, you can ignore this email. + HTML, + ], + [ + 'key' => 'password_reset', + 'subject' => 'Password reset', + 'body' => <<<'HTML' + # Password reset + + You are receiving this email because we received a password reset request for your account. + + **Reset password** + + + This password reset link will expire in 60 minutes. + + If you did not request a password reset, no further action is required. + + HTML, + ], + [ + 'key' => 'service_cancellation_received', + 'subject' => 'Service cancellation received', + 'body' => <<<'HTML' + # Server Cancellation Received + + We're sorry to see you go! Your server cancellation has been successfully received. + + **Cancellation Details** + - Server: {{ $service->product->name }} + @if($cancellation->reason) + - Reason: {{ $cancellation->reason }} + @endif + - Requested at: {{ $cancellation->created_at->format('F j, Y, g:i A') }} + + @if($cancellation->type === 'end_of_period') + Your server will remain active until {{ $service->expires_at->format('F j, Y') }} (end of your current billing period). + @else + Your server has been terminated immediately. + @endif + + HTML, + ], + ]); + } +} diff --git a/database/seeders/data/mail_css b/database/seeders/data/mail_css new file mode 100644 index 000000000..deceeb321 --- /dev/null +++ b/database/seeders/data/mail_css @@ -0,0 +1,290 @@ +/* Base */ + +body, +body *:not(html):not(style):not(br):not(tr):not(code) { + box-sizing: border-box; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, + 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; + position: relative; +} + +body { + -webkit-text-size-adjust: none; + background-color: #ffffff; + color: #718096; + height: 100%; + line-height: 1.4; + margin: 0; + padding: 0; + width: 100% !important; +} + +p, +ul, +ol, +blockquote { + line-height: 1.4; + text-align: left; +} + +a { + color: #3869d4; +} + +a img { + border: none; +} + +/* Typography */ + +h1 { + color: #3d4852; + font-size: 18px; + font-weight: bold; + margin-top: 0; + text-align: left; +} + +h2 { + font-size: 16px; + font-weight: bold; + margin-top: 0; + text-align: left; +} + +h3 { + font-size: 14px; + font-weight: bold; + margin-top: 0; + text-align: left; +} + +p { + font-size: 16px; + line-height: 1.5em; + margin-top: 0; + text-align: left; +} + +p.sub { + font-size: 12px; +} + +img { + max-width: 100%; +} + +/* Layout */ + +.wrapper { + -premailer-cellpadding: 0; + -premailer-cellspacing: 0; + -premailer-width: 100%; + background-color: #edf2f7; + margin: 0; + padding: 0; + width: 100%; +} + +.content { + -premailer-cellpadding: 0; + -premailer-cellspacing: 0; + -premailer-width: 100%; + margin: 0; + padding: 0; + width: 100%; +} + +/* Header */ + +.header { + padding: 25px 0; + text-align: center; +} + +.header a { + color: #3d4852; + font-size: 19px; + font-weight: bold; + text-decoration: none; +} + +/* Logo */ + +.logo { + height: 75px; + max-height: 75px; + width: 75px; +} + +/* Body */ + +.body { + -premailer-cellpadding: 0; + -premailer-cellspacing: 0; + -premailer-width: 100%; + background-color: #edf2f7; + border-bottom: 1px solid #edf2f7; + border-top: 1px solid #edf2f7; + margin: 0; + padding: 0; + width: 100%; +} + +.inner-body { + -premailer-cellpadding: 0; + -premailer-cellspacing: 0; + -premailer-width: 570px; + background-color: #ffffff; + border-color: #e8e5ef; + border-radius: 2px; + border-width: 1px; + box-shadow: 0 2px 0 rgba(0, 0, 150, 0.025), 2px 4px 0 rgba(0, 0, 150, 0.015); + margin: 0 auto; + padding: 0; + width: 570px; +} + +/* Subcopy */ + +.subcopy { + border-top: 1px solid #e8e5ef; + margin-top: 25px; + padding-top: 25px; +} + +.subcopy p { + font-size: 14px; +} + +/* Footer */ + +.footer { + -premailer-cellpadding: 0; + -premailer-cellspacing: 0; + -premailer-width: 570px; + margin: 0 auto; + padding: 0; + text-align: center; + width: 570px; +} + +.footer p { + color: #b0adc5; + font-size: 12px; + text-align: center; +} + +.footer a { + color: #b0adc5; + text-decoration: underline; +} + +/* Tables */ + +.table table { + -premailer-cellpadding: 0; + -premailer-cellspacing: 0; + -premailer-width: 100%; + margin: 30px auto; + width: 100%; +} + +.table th { + border-bottom: 1px solid #edeff2; + margin: 0; + padding-bottom: 8px; +} + +.table td { + color: #74787e; + font-size: 15px; + line-height: 18px; + margin: 0; + padding: 10px 0; +} + +.content-cell { + max-width: 100vw; + padding: 32px; +} + +/* Buttons */ + +.action { + -premailer-cellpadding: 0; + -premailer-cellspacing: 0; + -premailer-width: 100%; + margin: 30px auto; + padding: 0; + text-align: center; + width: 100%; +} + +.button { + -webkit-text-size-adjust: none; + border-radius: 4px; + color: #fff; + display: inline-block; + overflow: hidden; + text-decoration: none; +} + +.button-blue, +.button-primary { + background-color: #2d3748; + border-bottom: 8px solid #2d3748; + border-left: 18px solid #2d3748; + border-right: 18px solid #2d3748; + border-top: 8px solid #2d3748; +} + +.button-green, +.button-success { + background-color: #48bb78; + border-bottom: 8px solid #48bb78; + border-left: 18px solid #48bb78; + border-right: 18px solid #48bb78; + border-top: 8px solid #48bb78; +} + +.button-red, +.button-error { + background-color: #e53e3e; + border-bottom: 8px solid #e53e3e; + border-left: 18px solid #e53e3e; + border-right: 18px solid #e53e3e; + border-top: 8px solid #e53e3e; +} + +/* Panels */ + +.panel { + border-left: #2d3748 solid 4px; + margin: 21px 0; +} + +.panel-content { + background-color: #edf2f7; + color: #718096; + padding: 16px; +} + +.panel-content p { + color: #718096; +} + +.panel-item { + padding: 0; +} + +.panel-item p:last-of-type { + margin-bottom: 0; + padding-bottom: 0; +} + +/* Utilities */ + +.break-all { + word-break: break-all; +} \ No newline at end of file diff --git a/database/seeders/data/mail_footer b/database/seeders/data/mail_footer new file mode 100644 index 000000000..819099359 --- /dev/null +++ b/database/seeders/data/mail_footer @@ -0,0 +1,11 @@ + + + + + + + + + diff --git a/database/seeders/data/mail_header b/database/seeders/data/mail_header new file mode 100644 index 000000000..f7486c072 --- /dev/null +++ b/database/seeders/data/mail_header @@ -0,0 +1,51 @@ + + + +{{ config('app.name') }} + + + + + + + + + + +