commit 85c03cef826c929a0a12846386dea919c4941442 Author: Muhammad Tamir Date: Fri Nov 14 10:57:49 2025 +0700 v1.3.4 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..61404f7 --- /dev/null +++ b/.env.example @@ -0,0 +1,38 @@ +APP_NAME=Paymenter +APP_ENV=production +APP_KEY= +APP_DEBUG=false +APP_TIMEZONE=UTC + +APP_LOCALE=en +APP_FALLBACK_LOCALE=en +APP_FAKER_LOCALE=en_US + +APP_MAINTENANCE_DRIVER=file +APP_MAINTENANCE_STORE=database + +BCRYPT_ROUNDS=12 + +LOG_CHANNEL=stack +LOG_STACK=daily +LOG_DEPRECATIONS_CHANNEL=null +LOG_LEVEL=debug + +DB_CONNECTION=mariadb +DB_HOST=127.0.0.1 +DB_PORT=3306 +DB_DATABASE=paymenter +DB_USERNAME=paymenter +DB_PASSWORD= + +BROADCAST_CONNECTION=log +CACHE_STORE=redis +FILESYSTEM_DISK=local +SESSION_LIFETIME=120 + +MEMCACHED_HOST=127.0.0.1 + +REDIS_CLIENT=phpredis +REDIS_HOST=127.0.0.1 +REDIS_PASSWORD=null +REDIS_PORT=6379 \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d5e2b1b --- /dev/null +++ b/.gitignore @@ -0,0 +1,24 @@ +/.phpunit.cache +/node_modules +/public/default/* +/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/* +extensions/Others/Pages +extensions/Servers/LicenseManager +extensions/Servers/Proxmox +!themes/default \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..ad78743 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,41 @@ +# 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 AS final +WORKDIR /app + +COPY . ./ +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 + +FROM --platform=$TARGETOS/$TARGETARCH node:22-alpine AS build +WORKDIR /app +COPY . ./ +COPY --from=final /app/vendor /app/vendor +RUN npm install \ + && npm run build + +# Switch back to the final stage +FROM final AS production +COPY --from=build /app/public /app/public + +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/LICENSE b/LICENSE new file mode 100644 index 0000000..0306099 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024-2025 Paymenter + +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/README.md b/README.md new file mode 100644 index 0000000..e0a8914 --- /dev/null +++ b/README.md @@ -0,0 +1,85 @@ +
+

+ + + + + + Paymenter Isotype + + +

+

+ Paymenter +

+ +
+

Open-Source Billing, Built for Hosting

+

Automate subscriptions, eliminate billing chaos, and grow your hosting business – without vendor lock-ins or hidden costs.

+
+ +

+ Website · + Documentation · + Live Demo · + Extensions +

+ +
+ + [![License](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/Paymenter/paymenter/blob/master/LICENSE) + [![Downloads](https://img.shields.io/github/downloads/paymenter/paymenter/total)]() + [![GitHub release (latest by date)](https://img.shields.io/github/v/release/paymenter/paymenter)](https://github.com/Paymenter/paymenter/releases) +
+
+ [![Discord](https://img.shields.io/discord/882318291014651924?logo=discord&labelColor=white&color=5865f2)](https://discord.gg/xB4UUT3XQg) + +
+ +
+ + + + + Paymenter Dashboard + +
+
+ +## Getting Started + +#### Installation & Documentation + +For a detailed explanation of how to install and configure Paymenter, take a look at our [documentation here](https://paymenter.org/docs/getting-started/introduction/). + +Or, get additional help via [Community Discord](https://discord.gg/xB4UUT3XQg). + +#### Requirements + +The requirements for Paymenter are the following: + +- PHP (8.2 or higher). +- Composer +- Webserver (Apache or Nginx) +- Database (MariaDB) + +## What is Paymenter? + +Paymenter is an open-source billing platform tailored for hosting companies. It simplifies the management of hosting services, providing a seamless experience for both providers and customers. Built on modern web technologies, Paymenter offers a flexible and robust solution for your hosting business needs. + +### Key Features: +- User-Friendly Interface: Paymenter is designed with simplicity in mind, ensuring an intuitive experience for users of all technical levels. +- Open Source and Extensible: As an open-source platform, Paymenter encourages community contributions and customization. Its architecture allows for extensive modifications and integration with other tools. +- Efficient Management: Streamline your operations with Paymenter powerful admin panel, designed to enhance productivity and reduce overhead. +- Secure and Reliable: Built with security as a priority, Paymenter ensures the protection of your data and transactions. +- Community Driven: Join an engaged community of developers and hosting providers to collaborate and drive the future development of Paymenter. + +Paymenter is available under the MIT license, offering you the freedom to adapt and evolve the platform to meet your specific requirements. + +## Sponsors + +Thanks to all sponsors for helping fund Paymenter's development. [Interested in becoming a sponsor?](https://github.com/sponsors/Paymenter) + +## License + +Licensed under the [MIT License](https://github.com/Paymenter/Paymenter/blob/master/LICENSE). diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..17e0168 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,11 @@ +# Security Policy + +## Supported Versions +| Version | Supported | +| ------- | ------------------ | +| >= 0.9 | :white_check_mark: | +| < 0.9.X | :x: | + +## Reporting a Vulnerability + +If you found a security vulnerability create a new security [advisory](https://github.com/Paymenter/Paymenter/security/advisories/new) OR send an email to corwin@paymenter.org \ No newline at end of file diff --git a/app/Admin/Actions/AuditAction.php b/app/Admin/Actions/AuditAction.php new file mode 100644 index 0000000..2cb8128 --- /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('📜') + ->color('gray') + ->slideOver() + ->modalContent(fn (Model $record): View => view( + 'admin.actions.audits', + ['record' => $record, 'children' => $this->auditChildren] + )) + ->modalHeading('Audit Log') + ->modalWidth(Width::ExtraLarge) + ->modalSubmitAction(false) + ->modalCancelAction(false); + } +} diff --git a/app/Admin/Clusters/Extensions.php b/app/Admin/Clusters/Extensions.php new file mode 100644 index 0000000..1795791 --- /dev/null +++ b/app/Admin/Clusters/Extensions.php @@ -0,0 +1,18 @@ +count() ?: null; + } + + public static function getNavigationBadgeColor(): ?string + { + return 'warning'; + } +} diff --git a/app/Admin/Clusters/Services.php b/app/Admin/Clusters/Services.php new file mode 100644 index 0000000..7f1b61c --- /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 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 0000000..3eff857 --- /dev/null +++ b/app/Admin/Pages/Dashboard.php @@ -0,0 +1,12 @@ +allExtensions = Cache::remember('paymenter_marketplace_extensions', now()->addHours(6), function () { + $response = Http::timeout(15) + ->withUserAgent('Paymenter/' . config('app.version') . ' (https://paymenter.org)') + ->get('https://api.paymenter.org/extensions', ['limit' => 999]); + if (!$response->successful()) { + logger()->error('Paymenter Marketplace API request failed', ['status' => $response->status(), 'body' => $response->body()]); + + return null; + } + + return $response->json('extensions', []); + }); + if (is_null($this->allExtensions)) { + $this->error = 'The Paymenter Marketplace is currently unavailable. Please try again later.'; + } + } catch (ConnectionException $e) { + $this->error = 'Failed to connect to the Paymenter Marketplace. Please check your server\'s internet connection.'; + logger()->error('Paymenter Marketplace API connection failed: ' . $e->getMessage()); + } + } + + public function updatedSearch(): void + { + $this->resetLoadedItems(); + } + + public function updatedFilter(): void + { + $this->resetLoadedItems(); + } + + public function loadMore(): void + { + $this->loadedItems += self::PER_PAGE; + } + + private function resetLoadedItems(): void + { + $this->loadedItems = self::PER_PAGE; + } + + public function getFilteredExtensionsProperty(): Collection + { + if (is_null($this->allExtensions)) { + return collect(); + } + + return collect($this->allExtensions) + ->when($this->search, fn (Collection $c) => $c->filter(fn ($i) => stripos($i['name'], $this->search) !== false)) + ->when($this->filter !== 'all', fn (Collection $c) => $c->where('type', $this->filter)); + } + + public function getCanLoadMoreProperty(): bool + { + return $this->filteredExtensions->count() > $this->loadedItems; + } + + public function getExtensionsProperty(): Collection + { + return $this->filteredExtensions->take($this->loadedItems); + } + + public function table(Table $table): Table + { + return $table + ->records(fn () => collect(ExtensionHelper::getInstallableExtensions())) + ->description('List of available extensions (not gateway or server extensions) that can be installed.') + ->columns([ + TextColumn::make('meta.name') + ->label('Extension Name') + ->searchable() + ->sortable() + ->state(fn ($record) => $record['meta'] ? $record['meta']->name . ' (' . $record['meta']->author . ')' : $record['name']), + TextColumn::make('meta.description') + ->label('Description') + ->searchable() + ->sortable(), + ]) + ->recordActions([ + Action::make('install') + ->label('Install') + ->action(function ($record) { + $extension = \App\Models\Extension::create([ + 'name' => $record['name'], + 'type' => $record['type'], + 'extension' => $record['name'], + ]); + ExtensionHelper::call($extension, 'installed', mayFail: true); + + Notification::make() + ->title('Extension Installed') + ->body('The extension has been successfully installed.') + ->success() + ->send(); + + $this->redirect(ExtensionResource::getUrl('edit', ['record' => $extension->id]), true); + }) + ->requiresConfirmation(), + ]) + ->headerActions([ + Action::make('upload') + ->label('Upload Extension') + ->icon('ri-upload-2-line') + ->form([ + FileUpload::make('file') + ->label('Extension File') + ->required() + ->acceptedFileTypes(['application/zip', 'application/x-zip-compressed']) + ->directory('extensions/uploaded') + ->preserveFilenames() + ->maxSize(10240), // 10 MB + ]) + ->action(function (array $data, UploadExtensionService $service) { + try { + $type = $service->handle(storage_path('app/' . $data['file'])); + // Handle the exception, e.g., log it or show an error message + switch ($type) { + case 'server': + Notification::make() + ->title('Extension uploaded successfully') + ->body('Server uploaded successfully. Please go to the Servers page to install the new server extension.') + ->success() + ->send(); + break; + case 'gateway': + Notification::make() + ->title('Extension uploaded successfully') + ->body('Gateway uploaded successfully. Please go to the Gateways page to install the new gateway extension.') + ->success() + ->send(); + break; + default: + // Unknown type, just stay on the page + Notification::make() + ->title('Extension uploaded successfully') + ->body('It should now be available on the "Ready to Install" tab.') + ->success() + ->send(); + break; + } + } catch (\Exception $e) { + Notification::make() + ->title('Failed to upload extension') + ->body($e->getMessage()) + ->danger() + ->send(); + } + }), + ]); + } +} diff --git a/app/Admin/Pages/Settings.php b/app/Admin/Pages/Settings.php new file mode 100644 index 0000000..d7ac123 --- /dev/null +++ b/app/Admin/Pages/Settings.php @@ -0,0 +1,179 @@ + $settings) { + foreach ($settings as $setting) { + $setting_values[$setting['name']] = config("settings.{$setting['name']}", $setting['default'] ?? null); + } + } + + $this->form->fill($setting_values); + } + + public function form(Schema $schema): Schema + { + $tabs = []; + + foreach (ClassesSettings::settings() as $key => $categories) { + $tab = 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([ + 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 $schema + ->components([ + Form::make([ + Tabs::make('Tabs') + ->tabs($tabs) + ->persistTabInQueryString(), + ]) + ->livewireSubmitHandler('save') + ->footer([ + Actions::make([ + Action::make('save') + ->submit('save') + ->keyBindings(['mod+s']), + ]), + ]), + ]) + ->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(ClassesSettings::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', + 'encrypted' => $avSetting->encrypted ?? false, + ]); + } 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 (ClassesSettings::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 canAccess(): bool + { + /** @var 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 0000000..6e5bf66 --- /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 0000000..690f0e9 --- /dev/null +++ b/app/Admin/Resources/ApiResource.php @@ -0,0 +1,99 @@ + Arr::dot( + array_merge_recursive(...Event::dispatch('api.permissions', [])) + )); + + return $schema + ->components([ + TextInput::make('name') + ->required() + ->maxLength(255) + ->label('API Name'), + TagsInput::make('ip_addresses') + ->label('Allowed IP Addresses') + ->placeholder('Enter IP addresses'), + Toggle::make('enabled') + ->default(true) + ->visibleOn('edit') + ->label('Active Status'), + + 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([ + TextColumn::make('name'), + TextColumn::make('ip_addresses') + ->formatStateUsing(fn ($state) => is_array($state) ? implode(', ', $state) : $state) + ->label('Allowed IP Addresses'), + IconColumn::make('enabled') + ->boolean(), + TextColumn::make('last_used_at') + ->dateTime() + ->label('Last Used At') + ->sortable() + ->toggleable(), + ]) + ->filters([ + // + ]) + ->recordActions([ + EditAction::make(), + DeleteAction::make(), + ]) + ->toolbarActions([ + BulkActionGroup::make([ + DeleteBulkAction::make(), + ]), + ]); + } + + public static function getPages(): array + { + return [ + 'index' => 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 0000000..2e373d2 --- /dev/null +++ b/app/Admin/Resources/ApiResource/Pages/ManageApis.php @@ -0,0 +1,35 @@ +mutateDataUsing(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/Audits/AuditResource.php b/app/Admin/Resources/Audits/AuditResource.php new file mode 100644 index 0000000..d211558 --- /dev/null +++ b/app/Admin/Resources/Audits/AuditResource.php @@ -0,0 +1,49 @@ + ListAudits::route('/'), + 'view' => ViewAudit::route('/{record}'), + ]; + } +} diff --git a/app/Admin/Resources/Audits/Pages/ListAudits.php b/app/Admin/Resources/Audits/Pages/ListAudits.php new file mode 100644 index 0000000..d5008dc --- /dev/null +++ b/app/Admin/Resources/Audits/Pages/ListAudits.php @@ -0,0 +1,11 @@ +components([ + TextEntry::make('user_id') + ->url(fn (Audit $record): string => $record->user_id ? UserResource::getUrl('edit', [$record->user_id]) : '') + ->formatStateUsing(fn (Audit $record): string => $record->user ? $record->user->name : 'User #' . $record->user_id) + ->placeholder('System'), + TextEntry::make('event') + ->formatStateUsing(fn (Audit $record): string => $record->event . ' - ' . class_basename($record->auditable_type) . ' (' . $record->auditable_id . ')') + ->url(function (Audit $record) { + if ($record->event != 'deleted' && isset(AuditsTable::TYPE_TO_RESOURCE[class_basename($record->auditable_type)])) { + return AuditsTable::TYPE_TO_RESOURCE[class_basename($record->auditable_type)]::getUrl('edit', [$record->auditable_id]); + } + }), + TextEntry::make('user_agent'), + TextEntry::make('ip_address'), + TextEntry::make('url'), + TextEntry::make('created_at') + ->dateTime(), + ViewEntry::make('changes') + ->label('Changes') + ->view('admin.infolists.components.difference') + ->columnSpanFull(), + + ]); + } +} diff --git a/app/Admin/Resources/Audits/Tables/AuditsTable.php b/app/Admin/Resources/Audits/Tables/AuditsTable.php new file mode 100644 index 0000000..a27ee7c --- /dev/null +++ b/app/Admin/Resources/Audits/Tables/AuditsTable.php @@ -0,0 +1,80 @@ + UserResource::class, + 'Product' => ProductResource::class, + 'Order' => OrderResource::class, + 'Invoice' => InvoiceResource::class, + 'Category' => CategoryResource::class, + 'Service' => ServiceResource::class, + 'TaxRate' => TaxRateResource::class, + 'Ticket' => TicketResource::class, + ]; + + public static function configure(Table $table): Table + { + return $table + ->columns([ + TextColumn::make('user_id') + ->url(fn (Audit $record): string => $record->user_id ? UserResource::getUrl('edit', [$record->user_id]) : '') + ->formatStateUsing(fn (Audit $record): string => $record->user ? $record->user->name : 'User #' . $record->user_id) + ->placeholder('System') + ->sortable(), + TextColumn::make('event') + ->formatStateUsing(fn (Audit $record): string => $record->event . ' - ' . class_basename($record->auditable_type) . ' (' . $record->auditable_id . ')') + ->url(function (Audit $record) { + if ($record->event != 'deleted' && isset(self::TYPE_TO_RESOURCE[class_basename($record->auditable_type)])) { + return self::TYPE_TO_RESOURCE[class_basename($record->auditable_type)]::getUrl('edit', [$record->auditable_id]); + } + }) + ->searchable(), + TextColumn::make('ip_address') + ->searchable(), + TextColumn::make('user_agent') + ->toggleable(isToggledHiddenByDefault: true) + ->searchable(), + TextColumn::make('created_at') + ->dateTime() + ->sortable() + ->toggleable(isToggledHiddenByDefault: true), + TextColumn::make('updated_at') + ->dateTime() + ->sortable() + ->toggleable(isToggledHiddenByDefault: true), + ]) + ->filters([ + // + ]) + ->recordActions([ + ViewAction::make(), + ]) + ->defaultSort(function (Builder $query) { + return $query->orderBy('created_at', 'desc')->orderBy('id', 'desc'); + }) + ->toolbarActions([ + BulkActionGroup::make([ + DeleteBulkAction::make(), + ]), + ]); + } +} diff --git a/app/Admin/Resources/CategoryResource.php b/app/Admin/Resources/CategoryResource.php new file mode 100644 index 0000000..f4da53a --- /dev/null +++ b/app/Admin/Resources/CategoryResource.php @@ -0,0 +1,131 @@ +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)); + }), + TextInput::make('slug') + ->required(), + RichEditor::make('description') + ->required(), + 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), + FileUpload::make('image') + ->label('Image') + ->nullable() + ->visibility('public') + ->disk('public') + ->acceptedFileTypes(['image/*']) + ->columnSpanFull(), + ])->columns(2); + } + + public static function table(Table $table): Table + { + return $table + ->columns([ + TextColumn::make('name') + ->searchable() + ->sortable(), + TextColumn::make('slug') + ->searchable() + ->sortable(), + ]) + ->filters([ + // + ]) + ->recordActions([ + EditAction::make(), + ]) + ->toolbarActions([ + BulkActionGroup::make([ + DeleteBulkAction::make()->before(function (DeleteBulkAction $action, $records) { + foreach ($records 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' => ListCategories::route('/'), + 'create' => CreateCategory::route('/create'), + 'edit' => 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 0000000..60adbba --- /dev/null +++ b/app/Admin/Resources/CategoryResource/Pages/CreateCategory.php @@ -0,0 +1,11 @@ +before(function (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 0000000..e1b3419 --- /dev/null +++ b/app/Admin/Resources/CategoryResource/Pages/ListCategories.php @@ -0,0 +1,19 @@ +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(), + TextInput::make('name')->translateLabel()->nullable(), + TextInput::make('key')->translateLabel()->required(), + TextInput::make('value')->translateLabel()->required(), + ]); + } + + public function table(Table $table): Table + { + return $table + ->recordTitleAttribute('name') + ->columns([ + TextColumn::make('name'), + TextColumn::make('parent_property.name'), + TextColumn::make('key'), + TextInputColumn::make('value'), + ]) + ->filters([ + // + ]) + ->headerActions([ + CreateAction::make(), + ]) + ->recordActions([ + EditAction::make(), + DeleteAction::make(), + ]) + ->toolbarActions([ + BulkActionGroup::make([ + DeleteBulkAction::make(), + ]), + ]); + } +} diff --git a/app/Admin/Resources/ConfigOptionResource.php b/app/Admin/Resources/ConfigOptionResource.php new file mode 100644 index 0000000..5a11261 --- /dev/null +++ b/app/Admin/Resources/ConfigOptionResource.php @@ -0,0 +1,170 @@ +components([ + Tabs::make() + ->columnSpanFull() + ->schema([ + Tab::make('General')->schema([ + TextInput::make('name') + ->label('Name') + ->required() + ->maxLength(255) + ->placeholder('Enter the name of the configuration option'), + TextInput::make('env_variable') + ->label('Environment Variable') + ->maxLength(255) + ->placeholder('Enter the environment variable name'), + Select::make('type') + ->label('Type') + ->native(false) + ->required() + ->reactive() + ->options([ + 'text' => 'Text', + 'number' => 'Number', + 'select' => 'Select', + 'radio' => 'Radio', + 'checkbox' => 'Checkbox', + 'slider' => 'Slider', + ]), + Checkbox::make('hidden') + ->label('Hidden'), + 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.'), + Select::make('products') + ->label('Products') + ->relationship('products', 'name') + ->multiple() + ->preload() + ->placeholder('Select the products that this configuration option belongs to'), + ]), + Tab::make('Options') + ->visible(fn (Get $get): bool => in_array($get('type'), ['select', 'radio', 'slider', 'checkbox'])) + ->schema([ + 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([ + TextInput::make('name') + ->label('Name') + ->required() + ->live() + ->maxLength(255) + ->placeholder('Enter the name of the configuration option'), + 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([ + TextColumn::make('name') + ->label('Name') + ->searchable() + ->sortable(), + TextColumn::make('env_variable') + ->label('Environment Variable') + ->searchable() + ->sortable(), + TextColumn::make('type') + ->label('Type') + ->searchable() + ->sortable(), + TextColumn::make('hidden') + ->badge() + ->label('Hidden') + ->sortable(), + ]) + ->modifyQueryUsing(fn (Builder $query) => $query->where('parent_id', null)) + ->filters([ + // + ]) + ->recordActions([ + 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' => ListConfigOptions::route('/'), + 'create' => CreateConfigOption::route('/create'), + 'edit' => 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 0000000..b08b138 --- /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 0000000..93c7876 --- /dev/null +++ b/app/Admin/Resources/ConfigOptionResource/Pages/ListConfigOptions.php @@ -0,0 +1,19 @@ +components([ + TextInput::make('code') + ->label('Code') + ->required() + ->maxLength(255) + ->unique(static::getModel(), 'code', ignoreRecord: true) + ->placeholder('Enter the code of the coupon'), + + 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'), + + 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'), + + 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.'), + + TextInput::make('max_uses') + ->label('Max Uses') + ->numeric() + ->minValue(0) + ->placeholder('Enter the maximum number of total uses of the coupon'), + + TextInput::make('max_uses_per_user') + ->label('Max Uses Per User') + ->numeric() + ->minValue(0) + ->placeholder('Enter the maximum number of uses per user'), + + DatePicker::make('starts_at') + ->label('Starts At'), + + DatePicker::make('expires_at') + ->label('Expires At'), + + 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([ + TextColumn::make('code')->searchable(), + TextColumn::make('value')->searchable()->formatStateUsing(fn ($record) => $record->value . ($record->type === 'percentage' ? '%' : config('settings.default_currency'))), + ]) + ->filters([ + // + ]) + ->recordActions([ + EditAction::make(), + ]) + ->toolbarActions([ + BulkActionGroup::make([ + DeleteBulkAction::make(), + ]), + ]); + } + + public static function getRelations(): array + { + return [ + ServicesRelationManager::class, + ]; + } + + public static function getPages(): array + { + return [ + 'index' => ListCoupons::route('/'), + 'create' => CreateCoupon::route('/create'), + 'edit' => 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 0000000..f821678 --- /dev/null +++ b/app/Admin/Resources/CouponResource/Pages/CreateCoupon.php @@ -0,0 +1,11 @@ +recordTitleAttribute('id') + ->columns([ + TextColumn::make('id'), + TextColumn::make('order.user.name')->label('User'), + TextColumn::make('product.name')->label('Product'), + ]) + ->filters([ + // + ]) + ->headerActions([]) + ->recordActions([ + Action::make('view') + ->label('View') + ->url(fn ($record) => ServiceResource::getUrl('edit', ['record' => $record])), + ]) + ->toolbarActions([ + BulkActionGroup::make([ + DeleteBulkAction::make(), + ]), + ]); + } +} diff --git a/app/Admin/Resources/CurrencyResource.php b/app/Admin/Resources/CurrencyResource.php new file mode 100644 index 0000000..ef79076 --- /dev/null +++ b/app/Admin/Resources/CurrencyResource.php @@ -0,0 +1,111 @@ +components([ + TextInput::make('code') + ->label('Code') + ->required() + ->maxLength(3) + ->disabledOn('edit') + ->unique(static::getModel(), 'code', ignoreRecord: true) + ->placeholder('Enter the currency code'), + TextInput::make('name') + ->label('Name') + ->helperText('Display name for customers, e.g., US Dollar') + ->required() + ->maxLength(255) + ->placeholder('Enter the currency name'), + TextInput::make('prefix') + ->label('Prefix') + ->maxLength(10) + ->placeholder('Enter the currency prefix'), + TextInput::make('suffix') + ->label('Suffix') + ->maxLength(10) + ->placeholder('Enter the currency suffix'), + 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([ + TextColumn::make('code') + ->label('Code') + ->searchable() + ->sortable(), + TextColumn::make('prefix') + ->label('Prefix') + ->searchable() + ->sortable(), + TextColumn::make('suffix') + ->label('Suffix') + ->searchable() + ->sortable(), + ]) + ->filters([ + // + ]) + ->recordActions([ + EditAction::make(), + ]) + ->toolbarActions([ + BulkActionGroup::make([ + DeleteBulkAction::make(), + ]), + ]); + } + + public static function getRelations(): array + { + return [ + // + ]; + } + + public static function getPages(): array + { + return [ + 'index' => ListCurrencies::route('/'), + 'create' => CreateCurrency::route('/create'), + 'edit' => 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 0000000..f99aa75 --- /dev/null +++ b/app/Admin/Resources/CurrencyResource/Pages/CreateCurrency.php @@ -0,0 +1,11 @@ +record->code) { + return []; + } + + return [ + 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 0000000..3b2a028 --- /dev/null +++ b/app/Admin/Resources/CurrencyResource/Pages/ListCurrencies.php @@ -0,0 +1,19 @@ +components([ + TextInput::make('name') + ->required() + ->maxLength(255), + TextInput::make('key')->required(), + Select::make('model')->options([ + 'App\Models\User' => 'User', + ])->required(), + Select::make('type')->options([ + 'string' => 'Short Text', + 'text' => 'Long Text', + 'number' => 'Number', + 'select' => 'Select', + 'checkbox' => 'Checkbox', + 'radio' => 'Radio', + 'date' => 'Date', + ])->required(), + Textarea::make('description')->nullable()->columnSpanFull()->rows(2), + TagsInput::make('allowed_values')->nullable(), + TextInput::make('validation')->nullable(), + + Section::make() + ->columns([ + 'sm' => 1, + 'md' => 3, + ]) + ->schema([ + Toggle::make('non_editable')->default(false), + Toggle::make('required')->default(false), + Toggle::make('show_on_invoice')->default(false), + ]), + ]); + } + + public static function table(Table $table): Table + { + return $table + ->columns([ + TextColumn::make('name')->label('Property') + ->description(fn (CustomProperty $record): string => mb_strimwidth(($record->description ?? ''), + 0, + 75, + '...' + )), + TextColumn::make('key'), + TextColumn::make('type')->formatStateUsing(fn ($state) => str($state)->title()), + ToggleColumn::make('non_editable'), + ToggleColumn::make('required'), + ToggleColumn::make('show_on_invoice'), + ]) + ->filters([ + // + ]) + ->recordActions([ + EditAction::make(), + ]) + ->toolbarActions([ + BulkActionGroup::make([ + DeleteBulkAction::make(), + ]), + ])->defaultGroup('model'); + } + + public static function getRelations(): array + { + return [ + // + ]; + } + + public static function getPages(): array + { + return [ + 'index' => ListCustomProperty::route('/'), + 'create' => CreateCustomProperty::route('/create'), + 'edit' => 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 0000000..c258e0b --- /dev/null +++ b/app/Admin/Resources/CustomPropertyResource/Pages/CreateCustomProperty.php @@ -0,0 +1,11 @@ +components([ + TextEntry::make('subject') + ->size(TextSize::Medium) + ->label('Subject'), + TextEntry::make('to') + ->size(TextSize::Medium) + ->label('To'), + TextEntry::make('sent_at') + ->size(TextSize::Medium) + ->date() + ->hidden(fn ($state) => $state === null) + ->label('Sent At'), + 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'), + + TextEntry::make('error') + ->columnSpanFull() + ->size(TextSize::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([ + TextColumn::make('subject') + ->searchable(), + TextColumn::make('to') + ->searchable(), + TextColumn::make('sent_at') + ->dateTime() + ->sortable(), + 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([ + // + ]) + ->recordActions([ + ViewAction::make(), + ]); + } + + public static function getRelations(): array + { + return [ + // + ]; + } + + public static function getPages(): array + { + return [ + 'index' => ListEmailLogs::route('/'), + 'view' => 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 0000000..413685d --- /dev/null +++ b/app/Admin/Resources/EmailLogResource/Pages/ListEmailLogs.php @@ -0,0 +1,11 @@ +components([ + TextInput::make('key') + ->required() + ->disabledOn('edit') + ->maxLength(255), + TextInput::make('subject') + ->required() + ->maxLength(255), + Toggle::make('enabled') + ->required(), + MarkdownEditor::make('body') + ->hint('Use either Markdown or HTML to compose the email body.') + ->disableAllToolbarButtons() + ->required() + ->columnSpanFull(), + TagsInput::make('cc') + ->placeholder('mail@example.com') + ->nestedRecursiveRules(['required', 'email']) + ->columnSpanFull(), + TagsInput::make('bcc') + ->nestedRecursiveRules(['required', 'email']) + ->placeholder('mail@example.com') + ->columnSpanFull(), + ]); + } + + public static function table(Table $table): Table + { + return $table + ->columns([ + TextColumn::make('subject') + ->searchable(), + IconColumn::make('enabled') + ->boolean(), + ]) + ->filters([ + // + ]) + ->recordActions([ + EditAction::make(), + ]); + } + + public static function getRelations(): array + { + return [ + // + ]; + } + + public static function getPages(): array + { + return [ + 'index' => ListEmailTemplates::route('/'), + 'create' => CreateEmailTemplate::route('/create'), + 'edit' => 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 0000000..f629414 --- /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([ + TextColumn::make('message') + ->state(function (DebugLog $record) { + return $record->context['message'] ?? null; + }) + ->toggleable(), + TextColumn::make('file') + ->state(function (DebugLog $record) { + return $record->context['file'] ?? null; + }) + ->toggleable(), + TextColumn::make('line') + ->state(function (DebugLog $record) { + return $record->context['line'] ?? null; + }) + ->toggleable(), + TextColumn::make('created_at') + ->searchable() + ->toggleable(isToggledHiddenByDefault: true) + ->sortable(), + ]) + ->recordActions([ + ViewAction::make(), + ]) + ->toolbarActions([ + BulkActionGroup::make([ + DeleteBulkAction::make(), + ]), + ]) + ->defaultSort('created_at', 'desc'); + } + + public static function shouldRegisterNavigation(): bool + { + return config('settings.debug', false); + } + + public static function infolist(Schema $schema): Schema + { + return $schema + ->columns(1) + ->components([ + TextEntry::make('message') + ->state(function (DebugLog $record) { + return $record->context['message'] ?? null; + }), + TextEntry::make('file') + ->state(function (DebugLog $record) { + return $record->context['file'] ?? null; + }), + TextEntry::make('line') + ->state(function (DebugLog $record) { + return $record->context['line'] ?? null; + }), + + TextEntry::make('trace') + ->label('Trace') + ->state(function (DebugLog $record) { + return $record->context['trace'] ?? null; + }), + ]); + } + + public static function getPages(): array + { + return [ + 'index' => 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 0000000..751271e --- /dev/null +++ b/app/Admin/Resources/ErrorLogResource/Pages/ListErrorLogs.php @@ -0,0 +1,11 @@ +name; + } + + public static function form(Schema $schema): Schema + { + return $schema + ->components([ + Toggle::make('enabled'), + Section::make('Extension Settings') + ->columnSpanFull() + ->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([ + TextColumn::make('name') + ->searchable() + ->sortable(), + TextColumn::make('type') + ->searchable() + ->sortable(), + IconColumn::make('enabled') + ->label('Enabled') + ->boolean() + ->sortable(), + ]) + ->filters([ + // + ]) + ->recordActions([ + EditAction::make(), + ]) + ->headerActions([ + Action::make('create') + ->label('Install Extension') + ->url(fn () => \App\Admin\Pages\Extension::getUrl(['tab' => 'installable'])), + ]); + } + + public static function getEloquentQuery(): Builder + { + return parent::getEloquentQuery()->whereNotIn('type', ['gateway', 'server']); + } + + public static function getPages(): array + { + return [ + 'index' => ListExtensions::route('/'), + 'edit' => 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 0000000..e2bfbe2 --- /dev/null +++ b/app/Admin/Resources/ExtensionResource/Pages/EditExtension.php @@ -0,0 +1,87 @@ +requiresConfirmation() + ->color('danger') + ->label('Uninstall Extension') + ->modalDescription('Are you sure you want to uninstall this extension? This will remove all its data and settings.') + ->action(function (Model $record) { + // Call the extension's uninstalled method + ExtensionHelper::call($record, 'uninstalled', mayFail: true); + // Delete the record + $record->delete(); + + // Redirect to the list page + $this->redirect(ExtensionResource::getUrl('index'), true); + }), + ]; + } + + protected function mutateFormDataBeforeFill(array $data): array + { + foreach ($this->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 0000000..a8c376f --- /dev/null +++ b/app/Admin/Resources/ExtensionResource/Pages/ListExtensions.php @@ -0,0 +1,11 @@ +columns([ + TextColumn::make('uuid')->toggleable(isToggledHiddenByDefault: true), + TextColumn::make('payload')->formatStateUsing(function ($state) { + $state = json_decode($state); + + // List displayName + return $state->displayName; + }), + TextColumn::make('exception')->formatStateUsing(function ($state) { + return explode("\n", $state)[0]; + })->wrap()->tooltip(function ($state) { + return $state; + })->limit(200), + TextColumn::make('failed_at'), + ]) + ->poll() + ->recordActions([ + 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(); + }), + Action::make('delete') + ->label('Mark as Resolved') + ->requiresConfirmation() + ->color('danger') + ->action(function (FailedJob $failedJob) { + $failedJob->delete(); + }), + ]) + ->toolbarActions([ + 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(); + }) + ->deselectRecordsAfterCompletion(), + BulkAction::make('delete') + ->label('Mark as Resolved') + ->requiresConfirmation() + ->color('danger') + ->action(function (Collection $records): void { + foreach ($records as $record) { + $record->delete(); + } + Notification::make() + ->title("{$records->count()} jobs have been marked as resolved.") + ->success() + ->send(); + }) + ->deselectRecordsAfterCompletion(), + ]) + ->defaultSort('failed_at', 'desc') + ->filters([ + + ]); + } + + public static function canCreate(): bool + { + return false; + } + + public static function getPages(): array + { + return [ + 'index' => 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 0000000..b8a3e97 --- /dev/null +++ b/app/Admin/Resources/FailedJobResource/Pages/ListFailedJobs.php @@ -0,0 +1,42 @@ +count() > 0) { + $firstJob = DB::table('jobs')->orderBy('available_at', 'asc')->first(); + $firstJobAvailableAt = Carbon::parse($firstJob->available_at); + $now = 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 0000000..402196c --- /dev/null +++ b/app/Admin/Resources/GatewayResource.php @@ -0,0 +1,116 @@ +name; + } + + public static function form(Schema $schema): Schema + { + $gateways = ExtensionHelper::getExtensions('gateway'); + + return $schema + ->components([ + TextInput::make('name') + ->label('Name') + ->required() + ->maxLength(255) + ->unique(static::getModel(), 'name', ignoreRecord: true) + ->placeholder('Enter the name of the gateway'), + 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') + ->columnSpanFull() + ->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([ + TextColumn::make('name')->searchable(), + ]) + ->filters([ + // + ]) + ->recordActions([ + EditAction::make(), + ]) + ->toolbarActions([ + BulkActionGroup::make([ + DeleteBulkAction::make(), + ]), + ]); + } + + public static function getRelations(): array + { + return [ + // + ]; + } + + public static function getPages(): array + { + return [ + 'index' => ListGateways::route('/'), + 'create' => CreateGateway::route('/create'), + 'edit' => 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 0000000..a8d4bcf --- /dev/null +++ b/app/Admin/Resources/GatewayResource/Pages/CreateGateway.php @@ -0,0 +1,46 @@ + $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 0000000..763838d --- /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 0000000..d7f6add --- /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([ + TextColumn::make('method') + ->state(function (DebugLog $record) { + return $record->context['method'] ?? null; + }) + ->toggleable(), + TextColumn::make('url') + ->state(function (DebugLog $record) { + return $record->context['url'] ?? null; + }) + ->toggleable(), + TextColumn::make('status') + ->state(function (DebugLog $record) { + return $record->context['response_status'] ?? null; + }) + ->toggleable(), + TextColumn::make('created_at') + ->searchable() + ->toggleable(isToggledHiddenByDefault: true) + ->sortable(), + ]) + ->recordActions([ + ViewAction::make(), + ]) + ->toolbarActions([ + BulkActionGroup::make([ + DeleteBulkAction::make(), + ]), + ]) + ->defaultSort('created_at', 'desc'); + } + + public static function shouldRegisterNavigation(): bool + { + return config('settings.debug', false); + } + + public static function infolist(Schema $schema): Schema + { + return $schema + ->components([ + TextEntry::make('method') + ->state(function (DebugLog $record) { + return $record->context['method'] ?? null; + }), + TextEntry::make('url') + ->state(function (DebugLog $record) { + return $record->context['url'] ?? null; + }), + + TextEntry::make('status') + ->state(function (DebugLog $record) { + return $record->context['response_status'] ?? null; + }), + + ViewEntry::make('request') + ->label('Request') + ->view('admin.infolists.components.json') + ->state(function (DebugLog $record) { + return $record->context['payload'] ?? null; + })->columnSpanFull(), + + ViewEntry::make('request_headers') + ->label('Request Headers') + ->view('admin.infolists.components.json') + ->state(function (DebugLog $record) { + return $record->context['headers'] ?? null; + })->columnSpanFull(), + + ViewEntry::make('response_headers') + ->label('Response Headers') + ->view('admin.infolists.components.json') + ->state(function (DebugLog $record) { + return $record->context['response_headers'] ?? null; + })->columnSpanFull(), + + 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' => 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 0000000..b0fda72 --- /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 function form(Schema $schema): Schema + { + return $schema + ->components([ + UserComponent::make('user_id'), + TextInput::make('number') + ->label('Invoice Number') + ->helperText('The invoice number will be generated automatically') + ->disabled(), + DatePicker::make('created_at') + ->label('Issued At') + ->required() + ->default(now()) + ->placeholder('Select the date and time the invoice was issued'), + DatePicker::make('due_at') + ->label('Due At') + ->required() + ->default(now()->addDays(7)) + ->placeholder('Select the date and time the invoice is due'), + Select::make('status') + ->label('Status') + ->required() + ->options([ + 'paid' => 'Paid', + 'pending' => 'Pending', + 'cancelled' => 'Cancelled', + ]) + ->default('pending') + ->placeholder('Select the status of the invoice'), + Select::make('currency_code') + ->label('Currency') + ->required() + ->relationship('currency', 'code') + ->placeholder('Select the currency'), + Toggle::make('send_email') + ->label('Send Email') + ->hiddenOn('edit') + ->default(true), + Repeater::make('items') + ->relationship('items') + ->label('Items') + ->columnSpanFull() + ->columns(2) + ->schema([ + 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'), + TextInput::make('quantity') + ->label('Quantity') + ->required() + ->numeric() + ->placeholder('Enter the quantity of the product'), + TextInput::make('description') + ->label('Description') + ->required() + ->hintAction( + 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'), + Hidden::make('reference_type'), + Hidden::make('reference_id'), + ]), + + ]); + } + + public static function table(Table $table): Table + { + return $table + ->columns([ + TextColumn::make('id') + ->label('ID') + ->searchable() + ->sortable(), + TextColumn::make('number') + ->searchable() + ->sortable(), + 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%"))), + 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(), + TextColumn::make('created_at') + ->label('Issued At') + ->date() + ->searchable() + ->sortable(), + TextColumn::make('formattedTotal') + ->label('Total'), + 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', + ]), + ]) + ->recordActions([ + EditAction::make(), + ]) + ->toolbarActions([ + BulkActionGroup::make([ + DeleteBulkAction::make(), + ]), + ]); + } + + public static function getRelations(): array + { + return [ + TransactionsRelationManager::class, + ]; + } + + public static function getPages(): array + { + return [ + 'index' => ListInvoices::route('/'), + 'create' => CreateInvoice::route('/create'), + 'edit' => 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 0000000..a63705d --- /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 0000000..6dff115 --- /dev/null +++ b/app/Admin/Resources/InvoiceResource/Pages/EditInvoice.php @@ -0,0 +1,35 @@ +label('Download PDF') + ->action(function (Invoice $invoice) { + return response()->streamDownload(function () use ($invoice) { + echo PDF::generateInvoice($invoice)->stream(); + }, 'invoice-' . $invoice->number . '.pdf'); + }), + AuditAction::make() + ->auditChildren([ + 'items', + 'transactions', + ]), + ]; + } +} diff --git a/app/Admin/Resources/InvoiceResource/Pages/ListInvoices.php b/app/Admin/Resources/InvoiceResource/Pages/ListInvoices.php new file mode 100644 index 0000000..ac8b3f7 --- /dev/null +++ b/app/Admin/Resources/InvoiceResource/Pages/ListInvoices.php @@ -0,0 +1,19 @@ +components([ + Select::make('gateway.name') + ->label('Gateway') + ->relationship('gateway', 'name') + ->searchable() + ->preload() + ->placeholder('Select the gateway'), + TextInput::make('transaction_id') + ->label('Transaction ID'), + TextInput::make('amount') + ->label('Amount') + ->numeric() + ->mask(RawJs::make( + <<<'JS' + $money($input, '.', '', 2) + JS + )) + ->required(), + 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([ + TextColumn::make('gateway.name')->label('Gateway'), + TextColumn::make('transaction_id'), + TextColumn::make('formattedAmount')->label('Amount'), + TextColumn::make('formattedFee')->label('Fee'), + TextColumn::make('created_at'), + ]) + ->filters([ + // + ]) + ->headerActions([ + CreateAction::make(), + ]) + ->recordActions([ + DeleteAction::make(), + ]) + ->toolbarActions([ + BulkActionGroup::make([ + DeleteBulkAction::make(), + ]), + ]); + } +} diff --git a/app/Admin/Resources/InvoiceTransactions/InvoiceTransactionResource.php b/app/Admin/Resources/InvoiceTransactions/InvoiceTransactionResource.php new file mode 100644 index 0000000..2c12503 --- /dev/null +++ b/app/Admin/Resources/InvoiceTransactions/InvoiceTransactionResource.php @@ -0,0 +1,36 @@ + ListInvoiceTransactions::route('/'), + ]; + } +} diff --git a/app/Admin/Resources/InvoiceTransactions/Pages/ListInvoiceTransactions.php b/app/Admin/Resources/InvoiceTransactions/Pages/ListInvoiceTransactions.php new file mode 100644 index 0000000..e2e0ed1 --- /dev/null +++ b/app/Admin/Resources/InvoiceTransactions/Pages/ListInvoiceTransactions.php @@ -0,0 +1,11 @@ +columns([ + TextColumn::make('invoice_id') + ->url(fn (InvoiceTransaction $record): string => InvoiceResource::getUrl('edit', ['record' => $record->invoice_id])) + ->numeric() + ->sortable(), + TextColumn::make('gateway.name') + ->sortable(), + TextColumn::make('formattedAmount') + ->label('Amount') + ->sortable(), + TextColumn::make('formattedFee') + ->label('Fee') + ->sortable(), + TextColumn::make('transaction_id') + ->searchable(), + TextColumn::make('created_at') + ->dateTime() + ->sortable() + ->toggleable(isToggledHiddenByDefault: true), + TextColumn::make('updated_at') + ->dateTime() + ->sortable() + ->toggleable(isToggledHiddenByDefault: true), + ]) + ->filters([ + // + ]) + ->defaultSort('created_at', 'desc') + ->recordActions([ + DeleteAction::make(), + ]) + ->toolbarActions([ + BulkActionGroup::make([ + DeleteBulkAction::make(), + ]), + ]); + } +} diff --git a/app/Admin/Resources/OauthClientResource.php b/app/Admin/Resources/OauthClientResource.php new file mode 100644 index 0000000..bd8c81a --- /dev/null +++ b/app/Admin/Resources/OauthClientResource.php @@ -0,0 +1,109 @@ +components([ + TextInput::make('name') + ->required() + ->maxLength(255), + TagsInput::make('redirect') + ->required() + ->separator(',') + ->columnSpanFull(), + 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 });' + ); + }) + ), + 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([ + TextColumn::make('id') + ->label('ID'), + TextColumn::make('name') + ->searchable(), + ]) + ->filters([ + // + ]) + ->recordActions([ + EditAction::make(), + ]) + ->toolbarActions([ + BulkActionGroup::make([ + DeleteBulkAction::make(), + ]), + ]); + } + + public static function getRelations(): array + { + return [ + // + ]; + } + + public static function getPages(): array + { + return [ + 'index' => ListOauthClients::route('/'), + 'create' => CreateOauthClient::route('/create'), + 'edit' => 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 0000000..32f7bc4 --- /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 0000000..31b6bde --- /dev/null +++ b/app/Admin/Resources/OauthClientResource/Pages/EditOauthClient.php @@ -0,0 +1,19 @@ +components([ + 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()); + }), + 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()); + }) + ->options(Currency::query()->pluck('code', 'code')) + ->helperText('Does not convert the price, only displays the currency symbol') + ->placeholder('Select the currency'), + Repeater::make('services') + ->relationship('services') + ->label('Services') + ->columnSpanFull() + ->columns(2) + ->schema([ + Hidden::make('user_id') + ->default(fn (Get $get) => $get('../../user_id')), + Hidden::make('currency_code') + ->default(fn (Get $get) => $get('../../currency_code')), + 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'), + 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'), + TextInput::make('quantity') + ->label('Quantity') + ->required() + ->placeholder('Enter the quantity'), + 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'), + 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([ + TextColumn::make('id') + ->label('ID') + ->searchable() + ->sortable(), + 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%"))), + TextColumn::make('currency.code') + ->label('Currency') + ->searchable() + ->sortable(), + TextColumn::make('formattedTotal') + ->label('Total'), + TextColumn::make('updated_at') + ->label('Updated At') + ->searchable() + ->sortable(), + ]) + ->defaultSort(function (Builder $query): Builder { + return $query + ->orderBy('id', 'desc'); + }) + ->filters([ + // + ]) + ->recordActions([ + EditAction::make(), + ]) + ->toolbarActions([ + BulkActionGroup::make([ + DeleteBulkAction::make(), + ]), + ]); + } + + public static function getRelations(): array + { + return [ + ServiceRelationManager::class, + ]; + } + + public static function getPages(): array + { + return [ + 'index' => ListOrders::route('/'), + 'create' => CreateOrder::route('/create'), + 'edit' => 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 0000000..5822513 --- /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 0000000..b98709a --- /dev/null +++ b/app/Admin/Resources/OrderResource/Pages/EditOrder.php @@ -0,0 +1,19 @@ +recordTitleAttribute('product.name') + ->columns([ + TextColumn::make('product.name'), + TextColumn::make('quantity'), + TextColumn::make('formattedPrice')->label('Price'), + ]) + ->filters([ + // + ]) + ->recordActions([ + ViewAction::make()->url(fn ($record) => ServiceResource::getUrl('edit', ['record' => $record])), + ]) + ->toolbarActions([ + BulkActionGroup::make([ + DeleteBulkAction::make(), + ]), + ]); + } +} diff --git a/app/Admin/Resources/ProductResource.php b/app/Admin/Resources/ProductResource.php new file mode 100644 index 0000000..11e99ad --- /dev/null +++ b/app/Admin/Resources/ProductResource.php @@ -0,0 +1,335 @@ +components([ + Tabs::make('Tabs') + ->persistTabInQueryString() + ->tabs([ + Tab::make('General') + ->columns(2) + ->schema([ + 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)); + }), + TextInput::make('slug')->required()->unique(ignoreRecord: true), + TextInput::make('stock')->integer()->nullable(), + TextInput::make('per_user_limit')->integer()->nullable(), + Select::make('allow_quantity')->options([ + 'disabled' => 'No', + 'separated' => 'Separated', + 'combined' => 'Combined', + ])->default('separated') + ->required(), + Textarea::make('email_template') + ->hint('This snippet will be used in the email template.') + ->nullable(), + Checkbox::make('hidden') + ->label('Hide product') + ->hint('Hide the product from the client area.'), + + RichEditor::make('description')->nullable()->columnSpanFull(), + FileUpload::make('image') + ->label('Image') + ->nullable() + ->visibility('public') + ->imageEditor() + ->image() + ->disk('public') + ->acceptedFileTypes(['image/*']), + Select::make('category_id') + ->relationship('category', 'name') + ->searchable() + ->preload() + ->createOptionForm(fn (Schema $schema) => CategoryResource::form($schema)) + ->required(), + ]), + Tab::make('Pricing') + ->schema([self::plan()]), + + Tab::make('Upgrades') + ->schema([ + // Select input for the products this product can upgrade to (hasmany relationship) + Select::make('upgrades') + ->label('Upgrades') + ->relationship('upgrades', 'name', ignoreRecord: true) + ->multiple() + ->preload() + ->placeholder('Select the products that this product can upgrade to'), + ]), + + Tab::make('Server') + ->schema([ + 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() + ->afterStateUpdated(fn (Select $component) => $component + ->getContainer() + ->getComponent('extension_settings') + ->getChildSchema() + ->fill()), + + Grid::make() + ->hidden(fn (Get $get) => $get('server_id') === null) + ->columns(2) + ->key('extension_settings') + ->schema( + function (Get $get, Component $livewire) { + $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[] = TextEntry::make('error')->state($e->getMessage()); + } + + return $settings; + } + ), + + ]), + ]), + ])->columns(1); + } + + public static function plan() + { + return 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([ + TextInput::make('name') + ->required() + ->live(onBlur: true) + ->maxLength(255), + 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'), + + TextInput::make('billing_period') + ->required() + ->label('Time Interval') + ->default(1) + ->hidden(fn (Get $get) => $get('type') !== 'recurring'), + + 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'), + 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([ + 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(), + 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'), + 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([ + TextColumn::make('name')->searchable(), + TextColumn::make('slug'), + TextColumn::make('category.name')->searchable(), + ]) + ->filters([ + SelectFilter::make('category') + ->relationship('category', 'name') + ->searchable() + ->preload(), + ]) + ->recordActions([ + EditAction::make(), + ]) + ->defaultSort(function (Builder $query): Builder { + return $query + ->orderBy('sort', 'asc'); + }) + ->reorderable('sort') + ->defaultGroup('category.name'); + } + + public static function getPages(): array + { + return [ + 'index' => ListProducts::route('/'), + 'create' => CreateProduct::route('/create'), + 'edit' => 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 0000000..71ab047 --- /dev/null +++ b/app/Admin/Resources/ProductResource/Pages/CreateProduct.php @@ -0,0 +1,47 @@ + $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 0000000..bfdf224 --- /dev/null +++ b/app/Admin/Resources/ProductResource/Pages/EditProduct.php @@ -0,0 +1,126 @@ +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); + }), + DeleteAction::make() + ->before(function (Product $record, 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(); + }), + AuditAction::make(), + ]; + } + + 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 0000000..c7b91a5 --- /dev/null +++ b/app/Admin/Resources/ProductResource/Pages/ListProducts.php @@ -0,0 +1,19 @@ + Arr::dot( + array_merge_recursive(...Event::dispatch('permissions', [])) + )); + + return $schema + ->components([ + TextInput::make('name') + ->unique(ignoreRecord: true) + ->required() + ->maxLength(255), + // Split permission UI into core and extension + CheckboxList::make('permissions') + ->label(false) + ->options(array_merge(Arr::dot(config('permissions.role')), $extensionPermissions)) + ->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([ + // + ]) + ->recordActions([ + 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' => ListRoles::route('/'), + 'create' => CreateRole::route('/create'), + 'edit' => EditRole::route('/{record}/edit'), + ]; + } +} diff --git a/app/Admin/Resources/RoleResource/Pages/CreateRole.php b/app/Admin/Resources/RoleResource/Pages/CreateRole.php new file mode 100644 index 0000000..fa41f63 --- /dev/null +++ b/app/Admin/Resources/RoleResource/Pages/CreateRole.php @@ -0,0 +1,11 @@ +name; + } + + public static function form(Schema $schema): Schema + { + $servers = ExtensionHelper::getExtensions('server'); + + return $schema + ->components([ + TextInput::make('name') + ->label('Name') + ->required() + ->maxLength(255) + ->unique(static::getModel(), 'name', ignoreRecord: true) + ->placeholder('Enter the name of the server'), + 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') + ->getChildSchema() + ->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') + ->columnSpanFull() + ->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([ + TextColumn::make('name')->searchable(), + ]) + ->filters([ + // + ]) + ->recordActions([ + EditAction::make(), + ]) + ->toolbarActions([ + BulkActionGroup::make([ + DeleteBulkAction::make(), + ]), + ]); + } + + public static function getRelations(): array + { + return [ + // + ]; + } + + public static function getPages(): array + { + return [ + 'index' => ListServers::route('/'), + 'create' => CreateServer::route('/create'), + 'edit' => 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 0000000..3e2760f --- /dev/null +++ b/app/Admin/Resources/ServerResource/Pages/CreateServer.php @@ -0,0 +1,46 @@ + $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 0000000..46e9a36 --- /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 0000000..649c4d0 --- /dev/null +++ b/app/Admin/Resources/ServerResource/Pages/ListServers.php @@ -0,0 +1,19 @@ +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(), + TextInput::make('reason') + ->maxLength(255) + ->default(null), + 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([ + TextColumn::make('service_id') + ->numeric() + ->sortable(), + TextColumn::make('reason') + ->searchable(), + TextColumn::make('type'), + TextColumn::make('deleted_at') + ->dateTime() + ->sortable() + ->toggleable(isToggledHiddenByDefault: true), + TextColumn::make('created_at') + ->dateTime() + ->sortable() + ->toggleable(isToggledHiddenByDefault: true), + TextColumn::make('updated_at') + ->dateTime() + ->sortable() + ->toggleable(isToggledHiddenByDefault: true), + ]) + ->filters([ + // + ]) + ->recordActions([ + EditAction::make(), + ]) + ->toolbarActions([ + BulkActionGroup::make([ + DeleteBulkAction::make(), + ]), + ]); + } + + public static function getRelations(): array + { + return [ + // + ]; + } + + public static function getPages(): array + { + return [ + 'index' => ListServiceCancellations::route('/'), + 'create' => CreateServiceCancellation::route('/create'), + 'edit' => 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 0000000..13ed087 --- /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(Schema $schema): Schema + { + return $schema + ->components([ + Select::make('product_id') + ->label('Product') + ->required() + ->options(Product::all()->pluck('name', 'id')->toArray()) + ->searchable() + ->live() + ->preload() + ->placeholder('Select the product'), + 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'), + Select::make('status') + ->label('Status') + ->required() + ->options([ + // active, pending, suspended, cancelled + 'active' => 'Active', + 'pending' => 'Pending', + 'suspended' => 'Suspended', + 'cancelled' => 'Cancelled', + ]) + ->default('pending'), + TextInput::make('quantity') + ->label('Quantity') + ->required() + ->placeholder('Enter the quantity'), + DatePicker::make('expires_at') + ->label('Expires At') + ->required() + ->placeholder('Select the expiration date'), + Select::make('coupon_id') + ->label('Coupon') + ->relationship('coupon', 'code') + ->searchable() + ->placeholder('Select the coupon'), + 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(), + 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), + 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([ + TextColumn::make('id') + ->label('ID') + ->sortable() + ->searchable(), + 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%"))), + TextColumn::make('product.name') + ->label('Product') + ->searchable() + ->sortable(), + 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(), + TextColumn::make('expires_at') + ->label('Expires At') + ->date() + ->searchable() + ->sortable(), + ]) + ->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'); + }) + ->recordActions([ + EditAction::make(), + ]) + ->toolbarActions([ + BulkActionGroup::make([ + DeleteBulkAction::make(), + ]), + ]); + } + + public static function getRelations(): array + { + return [ + InvoicesRelationManager::class, + PropertiesRelationManager::class, + ConfigOptionsRelationManager::class, + ]; + } + + public static function getPages(): array + { + return [ + 'index' => ListService::route('/'), + 'create' => CreateService::route('/create'), + 'edit' => 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 0000000..732bed4 --- /dev/null +++ b/app/Admin/Resources/ServiceResource/Pages/CreateService.php @@ -0,0 +1,11 @@ +label('Trigger Extension Action') + ->schema([ + 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, 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'), + AuditAction::make()->auditChildren([ + 'order', + 'invoices', + 'properties', + 'configs', + 'invoiceItems', + ]), + ]; + } +} diff --git a/app/Admin/Resources/ServiceResource/Pages/ListService.php b/app/Admin/Resources/ServiceResource/Pages/ListService.php new file mode 100644 index 0000000..456a261 --- /dev/null +++ b/app/Admin/Resources/ServiceResource/Pages/ListService.php @@ -0,0 +1,19 @@ +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([ + TextColumn::make('configOption.name'), + TextColumn::make('configValue.name'), + ]) + ->filters([ + // + ]) + ->headerActions([]) + ->recordActions([ + EditAction::make(), + 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 0000000..702f72b --- /dev/null +++ b/app/Admin/Resources/ServiceResource/RelationManagers/InvoicesRelationManager.php @@ -0,0 +1,72 @@ +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(Schema $schema): Schema + { + return $schema + ->components([ + TextInput::make('id') + ->required() + ->maxLength(255), + + ]); + } + + public function table(Table $table): Table + { + return $table + ->recordTitleAttribute('id') + ->columns([ + TextColumn::make('id')->sortable(), + TextColumn::make('formattedTotal')->label('Total'), + 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([]) + ->recordActions([ + 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 0000000..e5334d6 --- /dev/null +++ b/app/Admin/Resources/TaxRateResource.php @@ -0,0 +1,109 @@ + 'All Countries'] + config('app.countries'); + unset($countries['']); + + return $schema + ->components([ + TextInput::make('name') + ->label('Name') + ->required() + ->maxLength(255) + ->placeholder('Enter the name of the tax rate'), + TextInput::make('rate') + ->label('Rate') + ->mask(RawJs::make( + <<<'JS' + $money($input, '.', '', 2) + JS + )) + ->required() + ->suffix('%') + ->placeholder('Enter the rate of the tax rate'), + Select::make('country') + ->label('Country') + ->required() + ->unique(null, 'country', ignoreRecord: true) + ->options($countries), + ]); + } + + public static function table(Table $table): Table + { + return $table + ->columns([ + TextColumn::make('name') + ->label('Name') + ->searchable() + ->sortable(), + TextColumn::make('rate') + ->label('Rate') + ->searchable() + ->sortable(), + TextColumn::make('country') + ->formatStateUsing(fn (string $state): string => config('app.countries')[$state] ?? 'All Countries') + ->label('Country') + ->searchable() + ->sortable(), + ]) + ->filters([ + // + ]) + ->recordActions([ + EditAction::make(), + ]) + ->toolbarActions([ + BulkActionGroup::make([ + 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' => ListTaxRates::route('/'), + 'create' => CreateTaxRate::route('/create'), + 'edit' => 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 0000000..98b9ea2 --- /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(Schema $schema): Schema + { + return $schema + ->components([ + TextInput::make('subject') + ->label('Subject') + ->columnSpan(function ($record) { + return $record ? 2 : 1; + }) + ->required(), + Select::make('status') + ->label('Status') + ->options([ + 'open' => 'Open', + 'closed' => 'Closed', + 'replied' => 'Replied', + ]) + ->columnSpan(function ($record) { + return $record ? 2 : 1; + }) + ->default('open') + ->required(), + Select::make('priority') + ->label('Priority') + ->options([ + 'low' => 'Low', + 'medium' => 'Medium', + 'high' => 'High', + ]) + ->columnSpan(function ($record) { + return $record ? 2 : 1; + }) + ->default('medium') + ->required(), + 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; + }), + 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; + }), + 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')), + 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([ + TextColumn::make('subject') + ->searchable() + ->sortable(), + TextColumn::make('status') + ->sortable() + ->badge() + ->color(fn (Ticket $record) => match ($record->status) { + 'open' => 'success', + 'closed' => 'danger', + 'replied' => 'warning', + }) + ->formatStateUsing(fn (string $state) => ucfirst($state)), + TextColumn::make('priority') + ->sortable() + ->badge() + ->color(fn (Ticket $record) => match ($record->priority) { + 'low' => 'success', + 'medium' => 'gray', + 'high' => 'danger', + }) + ->formatStateUsing(fn (string $state) => ucfirst($state)), + TextColumn::make('department') + ->formatStateUsing(fn ($state) => array_combine(config('settings.ticket_departments'), config('settings.ticket_departments'))[$state]) + ->sortable(), + TextColumn::make('user.name') + ->searchable(['first_name', 'last_name']) + ->sortable(['first_name', 'last_name']), + ]) + ->filters([ + // + ]) + ->recordActions([ + EditAction::make(), + ]) + ->toolbarActions([ + BulkActionGroup::make([ + DeleteBulkAction::make(), + ]), + ]) + ->defaultSort('id', 'desc'); + } + + public static function getWidgets(): array + { + return [ + TicketsOverView::class, + ]; + } + + public static function getPages(): array + { + return [ + 'index' => ListTickets::route('/'), + 'create' => CreateTicket::route('/create'), + 'edit' => 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 0000000..a9e8cbf --- /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 0000000..61f9705 --- /dev/null +++ b/app/Admin/Resources/TicketResource/Pages/EditTicket.php @@ -0,0 +1,222 @@ +columns(1) + ->components([ + MarkdownEditor::make('message') + ->label('Message') + ->toolbarButtons([ + ['bold', 'italic', 'strike', 'link'], + ['heading'], + ['blockquote', 'codeBlock', 'bulletList', 'orderedList'], + ['table'], + ['undo', 'redo'], + ]) + ->required(fn (Get $get) => count($get('attachments')) == 0), + FileUpload::make('attachments') + ->label('Attachments') + ->visibility('private') + ->directory('tickets/uploads') + ->multiple() + ->storeFileNamesIn('attachment_names') + ->reorderable(), + ]); + } + + // Save action + public function send() + { + $data = $this->form->getState(); + + $message = $this->record->messages()->create([ + 'user_id' => Auth::id(), + 'message' => $data['message'] ?? '', + ]); + + // Move attachments + foreach ($data['attachments'] as $attachment) { + $originalPath = storage_path('app/' . $attachment); + $filename = $data['attachment_names'][$attachment]; + $mimeType = File::mimeType($originalPath); + $filesize = File::size($originalPath); + + $message->attachments()->create([ + 'path' => $attachment, + 'filename' => $filename, + 'mime_type' => $mimeType, + 'filesize' => $filesize, + ]); + } + + $this->form->fill(); + } + + public function infolist(Schema $schema): Schema + { + return $schema + ->record($this->record) + ->columns(['default' => 3, 'md' => 1]) + ->components([ + TextEntry::make('user_id') + ->size(TextSize::Large) + ->formatStateUsing(fn ($record) => $record->user->name) + ->url(fn ($record) => UserResource::getUrl('edit', ['record' => $record->user])) + ->label('User ID'), + TextEntry::make('subject') + ->size(TextSize::Large) + ->label('Subject'), + TextEntry::make('status') + ->size(TextSize::Large) + ->badge() + ->formatStateUsing(fn ($state) => ucfirst($state)) + ->color(fn ($state) => match ($state) { + 'open' => 'success', + 'closed' => 'danger', + 'replied' => 'gray', + }) + ->label('Status'), + TextEntry::make('priority') + ->size(TextSize::Large) + ->badge() + ->formatStateUsing(fn ($state) => ucfirst($state)) + ->color(fn ($state) => match ($state) { + 'low' => 'success', + 'medium' => 'gray', + 'high' => 'danger', + }) + ->label('Priority'), + TextEntry::make('department') + ->size(TextSize::Large) + ->formatStateUsing(fn ($state) => array_combine(config('settings.ticket_departments'), config('settings.ticket_departments'))[$state]) + ->placeholder('No department') + ->label('Department'), + + TextEntry::make('assigned_to') + ->size(TextSize::Large) + ->label('Assigned To') + ->placeholder('No assigned user') + ->formatStateUsing(fn ($record) => $record->assignedTo->name), + + TextEntry::make('service_id') + ->size(TextSize::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)), + + Actions::make([ + Action::make('Edit') + ->schema(function (Schema $schema) { + return $schema + ->columns(2) + ->components([ + Select::make('status') + ->label('Status') + ->options([ + 'open' => 'Open', + 'closed' => 'Closed', + 'replied' => 'Replied', + ]) + ->default('open') + ->required(), + Select::make('priority') + ->label('Priority') + ->options([ + 'low' => 'Low', + 'medium' => 'Medium', + 'high' => 'High', + ]) + ->default('medium') + ->required(), + Select::make('department') + ->label('Department') + ->options(array_combine(config('settings.ticket_departments'), config('settings.ticket_departments'))), + UserComponent::make('user_id'), + 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), + 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 (Action $action) { + Notification::make() + ->title(__('filament-actions::delete.single.notifications.deleted.title')) + ->success() + ->send(); + + $action->redirect(TicketResource::getUrl('index')); + }), + AuditAction::make()->auditChildren([ + 'messages', + ]), + ])->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 0000000..8ce6ae7 --- /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 0000000..23db36f --- /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 0000000..90fe4ec --- /dev/null +++ b/app/Admin/Resources/UserResource.php @@ -0,0 +1,143 @@ +name; + } + + public static function form(Schema $schema): Schema + { + return $schema + ->components([ + 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() + ->sortable() + ->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()->sortable(), + TextColumn::make('email')->searchable()->sortable(), + TextColumn::make('role.name')->sortable(), + TextColumn::make('created_at')->dateTime()->sortable(), + ]) + ->filters([ + SelectFilter::make('role') + ->relationship('role', 'name') + ->searchable() + ->preload(), + ]) + ->recordActions([ + EditAction::make(), + ]) + ->defaultSort('created_at', 'desc'); + } + + public static function getRelations(): array + { + return [ + PropertiesRelationManager::class, + ]; + } + + public static function getPages(): array + { + return [ + 'index' => ListUsers::route('/'), + 'create' => CreateUser::route('/create'), + 'edit' => EditUser::route('/{record}/edit'), + 'services' => ShowServices::route('/{record}/services'), + 'invoices' => ShowInvoices::route('/{record}/invoices'), + 'credits' => ShowCredits::route('/{record}/credits'), + ]; + } + + public static function getRecordSubNavigation(Page $page): array + { + + return $page->generateNavigationItems([ + EditUser::class, + ShowServices::class, + ShowInvoices::class, + 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 0000000..f821d0e --- /dev/null +++ b/app/Admin/Resources/UserResource/Pages/CreateUser.php @@ -0,0 +1,11 @@ +hidden(fn ($record) => Auth::user()->id == $record->id), + Action::make('impersonate') + ->label('Impersonate') + ->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), + AuditAction::make()->auditChildren([ + 'invoices', + 'services', + 'tickets', + 'credits', + 'orders', + ]), + ]; + } +} diff --git a/app/Admin/Resources/UserResource/Pages/ListUsers.php b/app/Admin/Resources/UserResource/Pages/ListUsers.php new file mode 100644 index 0000000..468a2e4 --- /dev/null +++ b/app/Admin/Resources/UserResource/Pages/ListUsers.php @@ -0,0 +1,19 @@ +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(), + 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([ + TextColumn::make('currency.code'), + TextColumn::make('formattedAmount')->label('Formatted Amount'), + TextInputColumn::make('amount')->label('Amount'), + ]) + ->filters([]) + ->headerActions([ + CreateAction::make()->disabled(function () { + $existing_currencies = $this->getOwnerRecord()->credits->pluck('currency_code'); + + return count(Currency::whereNotIn('code', $existing_currencies)->pluck('code', 'code')) <= 0; + }), + ]) + ->recordActions([ + 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 0000000..0c3e16c --- /dev/null +++ b/app/Admin/Resources/UserResource/Pages/ShowInvoices.php @@ -0,0 +1,57 @@ +recordTitleAttribute('product.name') + ->columns([ + TextColumn::make('id')->sortable(), + TextColumn::make('formattedTotal')->label('Total'), + 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', + ]), + ]) + ->recordActions([ + 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 0000000..a13e4c7 --- /dev/null +++ b/app/Admin/Resources/UserResource/Pages/ShowServices.php @@ -0,0 +1,41 @@ +recordTitleAttribute('product.name') + ->columns([ + TextColumn::make('product.name'), + TextColumn::make('quantity'), + TextColumn::make('formattedPrice')->label('Price'), + ]) + ->filters([ + // + ]) + ->recordActions([ + 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 0000000..2b98260 --- /dev/null +++ b/app/Admin/Widgets/ActiveUsers.php @@ -0,0 +1,38 @@ +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($this->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 0000000..3460fac --- /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 0000000..73e38df --- /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 - COALESCE(fee, 0)'); + + $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 0000000..c1ae217 --- /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([ + TextColumn::make('subject') + ->label('Subject'), + 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)), + TextColumn::make('user.name') + ->label('User'), + 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 0000000..7d1b803 --- /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 Coupon + * + * @throws 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 0000000..91d7d24 --- /dev/null +++ b/app/Classes/Extension/Extension.php @@ -0,0 +1,103 @@ +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 []; + } + + /** + * Called when the extension is installed for the first time + * If the extension type is server or gateway, it will be called when the first server or gateway is created + * + * @return void + */ + public function installed() {} + + /** + * Called when the extension is uninstalled + * If the extension type is server or gateway, it will be called when the last server or gateway is deleted + * + * @return void + */ + public function uninstalled() {} + + /** + * Called when the extension is updated + * This is called when the extension is updated to a new version + * + * @param string $oldVersion The old version of the extension + * @return void + */ + public function upgraded($oldVersion = null) {} + + /** + * Called every request to the extension (if the extension is enabled) + * + * @return void + */ + public function boot() {} + + /** + * Called when the extension is enabled + * If the extension type is server or gateway, it will be called every time a server or gateway is created + * + * @return void + */ + public function enabled() {} + + /** + * Called when the extension is disabled + * If the extension type is server or gateway, it will be called every time a server or gateway is deleted + * + * @return void + */ + public function disabled() {} +} diff --git a/app/Classes/Extension/Gateway.php b/app/Classes/Extension/Gateway.php new file mode 100644 index 0000000..c6d7863 --- /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 ?? null) + ->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 ?? null) + ->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 ?? null) + ->hintColor('primary') + ->rules($setting->validation ?? []) + ->nestedRecursiveRules($setting->nested_validation ?? []) + ->helperText($setting->description ?? null); + break; + + case 'text': + return TextInput::make($setting->name) + ->label($setting->label ?? $setting->name) + ->helperText($setting->description ?? null) + ->placeholder($setting->placeholder ?? $setting->default ?? '') + ->hint($setting->hint ?? null) + ->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 'time': + return TimePicker::make($setting->name) + ->label($setting->label ?? $setting->name) + ->helperText($setting->description ?? null) + ->placeholder($setting->placeholder ?? $setting->default ?? '') + ->hint($setting->hint ?? null) + ->hintColor('primary') + ->required($setting->required ?? false) + ->live(condition: $setting->live ?? false) + ->default($setting->default ?? null) + ->suffix($setting->suffix ?? null) + ->prefix($setting->prefix ?? null) + ->disabled($setting->disabled ?? false) + ->rules($setting->validation ?? []) + ->seconds($setting->seconds ?? false); + break; + + case 'textarea': + return Textarea::make($setting->name) + ->label($setting->label ?? $setting->name) + ->helperText($setting->description ?? null) + ->placeholder($setting->placeholder ?? $setting->default ?? '') + ->hint($setting->hint ?? null) + ->hintColor('primary') + ->required($setting->required ?? false) + ->live(condition: $setting->live ?? false) + ->default($setting->default ?? '') + ->rules($setting->validation ?? []) + ->disabled($setting->disabled ?? false); + break; + + case 'markdown': + return MarkdownEditor::make($setting->name) + ->label($setting->label ?? $setting->name) + ->helperText($setting->description ?? null) + ->placeholder($setting->placeholder ?? $setting->default ?? '') + ->hint($setting->hint ?? null) + ->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 ?? null) + ->placeholder($setting->placeholder ?? $setting->default ?? '') + ->hint($setting->hint ?? null) + ->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 ?? null) + ->placeholder($setting->placeholder ?? $setting->default ?? '') + ->hint($setting->hint ?? null) + ->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 ?? null) + ->placeholder($setting->placeholder ?? $setting->default ?? '') + ->hint($setting->hint ?? null) + ->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 ?? null) + ->placeholder($setting->placeholder ?? $setting->default ?? '') + ->hint($setting->hint ?? null) + ->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 ?? null) + ->hint($setting->hint ?? null) + ->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) + ->visibility($setting->visibility ?? 'private') + ->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 ?? null) + ->required($setting->required ?? false) + ->hint($setting->hint ?? null) + ->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 ?? null) + ->hint($setting->hint ?? null) + ->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 0000000..755795a --- /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()->livewireRoute(); + + 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 0000000..fd6b399 --- /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 0000000..ae24c7a --- /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 0000000..2fb677e --- /dev/null +++ b/app/Classes/Settings.php @@ -0,0 +1,634 @@ +toArray(); + }); + } catch (Exception $e) { + $currencies = []; + } + $settings = [ + // Split settings into groups (only used in the settings page for organization) + 'general' => [ + [ + 'name' => 'company_name', + 'label' => 'Company Name', + 'type' => 'text', + 'override' => 'app.name', + 'default' => 'Paymenter', + ], + [ + '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)', + 'nested_validation' => [ + new Cidr(allowWildCard: true), + ], + ], + ], + + '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, + ], + ], + '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, + ], + ], + 'tickets' => [ + [ + 'name' => 'ticket_departments', + 'label' => 'Ticket Departments', + 'type' => 'tags', + 'default' => ['Support', 'Sales'], + 'required' => true, + 'database_type' => 'array', + ], + // Email piping + [ + 'name' => 'ticket_mail_piping', + 'label' => 'Email Piping', + 'type' => 'checkbox', + 'database_type' => 'boolean', + 'default' => false, + 'live' => true, + ], + [ + 'name' => 'ticket_mail_host', + 'label' => 'Email Host', + 'type' => 'text', + 'required' => fn (Get $get) => $get('ticket_mail_piping'), + ], + [ + 'name' => 'ticket_mail_port', + 'label' => 'Email Port', + 'type' => 'number', + 'required' => fn (Get $get) => $get('ticket_mail_piping'), + 'default' => 993, + ], + [ + 'name' => 'ticket_mail_email', + 'label' => 'Email Address', + 'type' => 'email', + 'required' => fn (Get $get) => $get('ticket_mail_piping'), + ], + [ + 'name' => 'ticket_mail_password', + 'label' => 'Email Password', + 'type' => 'password', + 'required' => fn (Get $get) => $get('ticket_mail_piping'), + 'encrypted' => true, + ], + ], + + 'cronjob' => [ + [ + 'name' => 'cronjob_time', + 'label' => 'Cron Job Time', + 'type' => 'time', + 'default' => '00:00', + 'required' => true, + 'description' => 'Time the cron job should run daily (in 24 hour format, e.g. 14:00 for 2 PM).', + ], + [ + '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, + ], + [ + 'name' => 'credits_auto_use', + 'label' => 'Automatically use credits', + 'type' => 'checkbox', + 'database_type' => 'boolean', + 'default' => true, + 'description' => 'Automatically pay recurring invoices using available credits. (only pays if credits is more or equal to invoice amount)', + ], + [ + // Enable credits give back if and service is upgraded or downgraded + 'name' => 'credits_on_downgrade', + 'label' => 'Enable credits on service downgrade', + 'type' => 'checkbox', + 'database_type' => 'boolean', + 'default' => true, + 'description' => 'Enable giving back credits to users when they downgrade their service. The credits given back will be the prorated difference between the old and new service based on the remaining time in the billing cycle.', + ], + ], + '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' => 'bill_to_text', + 'label' => 'Bill To Text', + 'type' => 'textarea', + 'default' => '', + ], + [ + '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' => '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'], ...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 0000000..69746bf --- /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 0000000..ebe59f6 --- /dev/null +++ b/app/Classes/Theme.php @@ -0,0 +1,32 @@ +_ + $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 0000000..e0c2d79 --- /dev/null +++ b/app/Classes/helpers.php @@ -0,0 +1,36 @@ +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 0000000..32b946c --- /dev/null +++ b/app/Console/Commands/CronJob.php @@ -0,0 +1,192 @@ +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, + ]); + + $this->payInvoiceWithCredits($invoice->refresh()); + } 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']); + + if ($service->product->stock) { + $service->product->increment('stock', $service->quantity); + } + + $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']); + + if ($service->product->stock) { + $service->product->increment('stock', $service->quantity); + } + + $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); + } + + private function payInvoiceWithCredits(Invoice $invoice): void + { + if (!config('settings.credits_auto_use', true)) { + return; + } + $user = $invoice->user; + $credits = $user->credits()->where('currency_code', $invoice->currency_code)->first(); + if ($invoice->remaining > 0 && $credits && $credits->amount >= $invoice->remaining) { + $credits->amount -= $invoice->remaining; + $credits->save(); + + ExtensionHelper::addPayment($invoice->id, null, amount: $invoice->remaining); + } + } +} diff --git a/app/Console/Commands/Extension/Create.php b/app/Console/Commands/Extension/Create.php new file mode 100644 index 0000000..c95d1f5 --- /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 0000000..2c25d43 --- /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/Upgrade.php b/app/Console/Commands/Extension/Upgrade.php new file mode 100644 index 0000000..26a3a80 --- /dev/null +++ b/app/Console/Commands/Extension/Upgrade.php @@ -0,0 +1,45 @@ +argument('type')) . 's\\' . ucfirst($this->argument('name')) . '\\' . ucfirst($this->argument('name')); + if (!class_exists($extensionClass)) { + return $this->error("The extension class {$extensionClass} does not exist."); + } + + $extensionInstance = new $extensionClass; + if (method_exists($extensionInstance, 'upgraded')) { + try { + $extensionInstance->upgraded(); + } catch (\Exception $e) { + Log::error("Error during upgrade of extension {$this->argument('name')}: " . $e->getMessage()); + + return $this->error('An error occurred while upgrading the extension: ' . $e->getMessage()); + } + } + } +} diff --git a/app/Console/Commands/Extension/stubs/gateway.stub b/app/Console/Commands/Extension/stubs/gateway.stub new file mode 100644 index 0000000..87e8320 --- /dev/null +++ b/app/Console/Commands/Extension/stubs/gateway.stub @@ -0,0 +1,32 @@ +info('Email piping is not enabled. Skipping email fetch.'); + + return; + } + Config::set('audit.console', true); + + $mailbox = new Mailbox([ + 'port' => config('settings.ticket_mail_port'), + 'username' => config('settings.ticket_mail_email'), + 'password' => config('settings.ticket_mail_password'), + 'host' => config('settings.ticket_mail_host'), + ]); + + // Fetch emails from the mailbox + $emails = $mailbox->inbox(); + + foreach ($emails->messages()->since(now()->subDays(1))->withHeaders()->withBody()->get() as $email) { + if (TicketMailLog::where('message_id', $email->messageId())->exists()) { + continue; + } + + $body = \EmailReplyParser\EmailReplyParser::parseReply($email->text()); + + // Check headers to see if this email is a reply + $replyTo = $email->inReplyTo(); + if (!$replyTo) { + // Create email log but don't process + $this->failedEmailLog($email); + + continue; + } + + // Validate if in reply to another ticket (@hostname) + + if (!preg_match('/^(\d+)@/', $replyTo->email(), $matches)) { + $this->failedEmailLog($email); + + continue; + } + + $ticketMessageId = $matches[1]; + // Check if the ticket exists + $ticketMessage = TicketMessage::find($ticketMessageId); + if (!$ticketMessage) { + $this->failedEmailLog($email); + + continue; + } + + $ticket = $ticketMessage->ticket; + + // Check if from email matches ticket's email + if ($email->from()->email() !== $ticket->user->email) { + $this->failedEmailLog($email); + + continue; + } + + // // Log the successful email processing + $ticketMailLog = TicketMailLog::create([ + 'message_id' => $email->messageId(), + 'subject' => $email->subject(), + 'from' => $email->from()->email(), + 'to' => $email->to()[0]->email(), + 'body' => $email->text(), + 'status' => 'processed', + ]); + + // // Add reply to ticket + $message = $ticket->messages()->create([ + 'message' => $body, + 'user_id' => $ticket->user_id, + 'ticket_mail_log_id' => $ticketMailLog->id, + ]); + + // Foreach attachment + foreach ($email->attachments() as $attachment) { + $extension = pathinfo($attachment->filename(), PATHINFO_EXTENSION); + // Randomize filename + $newName = Str::ulid() . '.' . $extension; + $path = 'tickets/uploads/' . $newName; + + $attachment->save(storage_path('app/' . $path)); + + $message->attachments()->create([ + 'path' => $path, + 'filename' => $attachment->filename(), + 'mime_type' => File::mimeType(storage_path('app/' . $path)), + 'filesize' => File::size(storage_path('app/' . $path)), + ]); + } + } + } + + private function failedEmailLog($email): TicketMailLog + { + return TicketMailLog::create([ + 'message_id' => $email->messageId(), + 'subject' => $email->subject(), + 'from' => $email->from()->email(), + 'to' => $email->to()[0]->email(), + 'body' => $email->text(), + 'status' => 'unprocessed', + ]); + } +} diff --git a/app/Console/Commands/Init.php b/app/Console/Commands/Init.php new file mode 100644 index 0000000..5cdef33 --- /dev/null +++ b/app/Console/Commands/Init.php @@ -0,0 +1,43 @@ +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 0000000..266140b --- /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 0000000..23f173d --- /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 0000000..971d28f --- /dev/null +++ b/app/Console/Commands/MigrateOldData.php @@ -0,0 +1,1487 @@ +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 = 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 0000000..e06a9f0 --- /dev/null +++ b/app/Console/Commands/Settings/Change.php @@ -0,0 +1,66 @@ +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->value ?? ''); + } + }); + } + $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 0000000..0bc9312 --- /dev/null +++ b/app/Console/Commands/TelemetryCommand.php @@ -0,0 +1,96 @@ +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) + ->orWhereIn('type', ['server', 'gateway']) + ->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 0000000..4929963 --- /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 0000000..75b8100 --- /dev/null +++ b/app/Console/Commands/User/Create.php @@ -0,0 +1,62 @@ + $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 0000000..74614fa --- /dev/null +++ b/app/Console/Commands/User/PasswordReset.php @@ -0,0 +1,69 @@ +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 0000000..3303c32 --- /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 0000000..77e4cb5 --- /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 0000000..b63c320 --- /dev/null +++ b/app/Events/Setting/Saving.php @@ -0,0 +1,49 @@ +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 0000000..b534f3d --- /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 0000000..bb79a07 --- /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 0000000..f2460a4 --- /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 0000000..d4a8c72 --- /dev/null +++ b/app/Helpers/ExtensionHelper.php @@ -0,0 +1,598 @@ + !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 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; + } + + if (!file_exists($path) || !class_exists($class)) { + continue; + } + + $reflection = new ReflectionClass($class); + $attributes = $reflection->getAttributes(ExtensionMeta::class); + + $extensions[] = [ + 'name' => $name, + 'type' => $type, + 'meta' => $attributes ? $attributes[0]->newInstance() : null, + ]; + } + + // 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)) { + $reflection = new ReflectionClass('\\Paymenter\\Extensions\\' . ucfirst($type) . 's\\' . $name . '\\' . $name); + $attributes = $reflection->getAttributes(ExtensionMeta::class); + + $extensions[] = [ + 'name' => $name, + 'type' => $type, + 'meta' => $attributes ? $attributes[0]->newInstance() : null, + ]; + } + } + } + + return $extensions; + } + + public static function getInstallableExtensions() + { + $extensions = self::getExtensions('other'); + + // Filter out already installed extensions + $installedExtensions = Extension::all()->pluck('extension')->toArray(); + + return array_filter($extensions, fn ($extension) => !in_array($extension['name'], $installedExtensions)); + } + + 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]); + } + + /** + * 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]; + } + + protected static function prepareForSerialization($values) + { + if (is_array($values)) { + foreach ($values as $key => $value) { + $values[$key] = self::prepareForSerialization($value); + } + + return $values; + } + + if ($values instanceof TemporaryUploadedFile) { + // Store the file and use the path, or just use the filename if already stored + return $values->getRealPath() ?: (string) $values; + } + + return $values; + } + + /** + * Convert settings to array + * + * @param mixed $settings + * @return array + */ + public static function settingsToArray($settings) + { + $settingsArray = []; + + if ($settings instanceof Collection) { + // If $settings is a collection of models + foreach ($settings as $setting) { + $settingsArray[$setting->key] = $setting->value; + } + } elseif ($settings instanceof 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($total, $currency, $type, $items = []) + { + $gateways = []; + + foreach (Gateway::all() as $gateway) { + if (self::hasFunction($gateway, 'canUseGateway')) { + if (self::getExtension('gateway', $gateway->extension, $gateway->settings)->canUseGateway($total, $currency, $type, $items)) { + $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 + * + * @param Invoice|int $invoice + * @param Gateway|null $gateway + */ + 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) { + $transaction = $invoice->transactions()->create([ + 'gateway_id' => $gateway ? $gateway->id : null, + 'amount' => $amount, + 'fee' => $fee, + ]); + } else { + $transaction = $invoice->transactions()->updateOrCreate( + [ + 'transaction_id' => $transactionId, + ], + [ + 'gateway_id' => $gateway ? $gateway->id : null, + 'amount' => $amount, + 'fee' => $fee, + ] + ); + } + + return $transaction; + } + + /** + * 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']); + } + + /** + * Revert migrations for a specific extension + */ + public static function rollbackMigrations($path) + { + $migrationFiles = glob(base_path($path . '/*.php')); + + if (empty($migrationFiles)) { + return; + } + + // Sort by filename to ensure correct order + usort($migrationFiles, function ($a, $b) { + return strcmp(basename($a), basename($b)); + }); + + // Reverse the order to rollback in the correct sequence + $migrationFiles = array_reverse($migrationFiles); + + foreach ($migrationFiles as $file) { + $migrationName = basename($file, '.php'); + try { + $migration = require_once $file; + // return new class extends Migration + if (method_exists($migration, 'down') && DB::table('migrations')->where('migration', $migrationName)->exists()) { + $migration->down(); + DB::table('migrations')->where('migration', $migrationName)->delete(); + } + } catch (Exception $e) { + Log::error('Failed to rollback migration: ' . $migrationName . ' - ' . $e->getMessage()); + } + } + } + + /** + * Run migrations for a specific extension + */ + public static function runMigrations($path) + { + try { + Artisan::call('migrate', [ + '--path' => $path, + '--force' => true, + ]); + $output = Artisan::output(); + Log::debug('Migrations output: ' . $output); + } catch (Exception $e) { + Log::error('Failed to run migrations for path: ' . $path . ' - ' . $e->getMessage()); + } + } +} diff --git a/app/Helpers/NotificationHelper.php b/app/Helpers/NotificationHelper.php new file mode 100644 index 0000000..da8d5a5 --- /dev/null +++ b/app/Helpers/NotificationHelper.php @@ -0,0 +1,153 @@ +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, 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 0000000..ed54668 --- /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/InvoiceItemController.php b/app/Http/Controllers/Api/Admin/InvoiceItemController.php new file mode 100644 index 0000000..9820686 --- /dev/null +++ b/app/Http/Controllers/Api/Admin/InvoiceItemController.php @@ -0,0 +1,91 @@ +allowedFilters(['id', 'quantity', 'price', 'reference_type', 'reference_id']) + ->allowedIncludes($this->allowedIncludes(self::INCLUDES)) + ->allowedSorts(['id', 'created_at', 'updated_at', 'quantity', 'price']) + ->simplePaginate(request('per_page', 15)); + + // Return the invoices as a JSON response + return InvoiceItemResource::collection($invoices); + } + + /** + * Create a new invoice item + */ + public function store(CreateInvoiceItemRequest $request) + { + // Validate and create the invoice item + $invoiceItem = InvoiceItem::create($request->validated()); + + // Return the created invoice as a JSON response + return new InvoiceItemResource($invoiceItem); + } + + /** + * Show a specific invoice item + */ + public function show(GetInvoiceItemRequest $request, InvoiceItem $invoiceItem) + { + $invoiceItem = QueryBuilder::for(InvoiceItem::class) + ->allowedIncludes($this->allowedIncludes(self::INCLUDES)) + ->findOrFail($invoiceItem->id); + + // Return the invoice item as a JSON response + return new InvoiceItemResource($invoiceItem); + } + + /** + * Update a specific invoice item + */ + public function update(UpdateInvoiceItemRequest $request, InvoiceItem $invoiceItem) + { + // Validate and update the invoice item + $invoiceItem->update($request->validated()); + + // Return the updated invoice item as a JSON response + return new InvoiceItemResource($invoiceItem); + } + + /** + * Delete a specific invoice item + */ + public function destroy(DeleteInvoiceItemRequest $request, InvoiceItem $invoiceItem) + { + // Delete the invoice item + $invoiceItem->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 0000000..de4febc --- /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 0000000..6516bb1 --- /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 0000000..b9ac300 --- /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 0000000..86f6a9f --- /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 0000000..96fa30e --- /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 0000000..d5a0ee5 --- /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 0000000..ccc4010 --- /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 0000000..8677cd5 --- /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/Controllers/TicketAttachmentController.php b/app/Http/Controllers/TicketAttachmentController.php new file mode 100644 index 0000000..a850897 --- /dev/null +++ b/app/Http/Controllers/TicketAttachmentController.php @@ -0,0 +1,13 @@ +download($attachment->localPath, $attachment->filename); + } +} diff --git a/app/Http/Middleware/Api/AdminApi.php b/app/Http/Middleware/Api/AdminApi.php new file mode 100644 index 0000000..e3c060f --- /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 () { + abort(401, 'The provided API key is invalid or has been disabled.'); + }); + + // Check if the token is of type 'admin' + if ($token->type !== 'admin' || ($token->ip_addresses && !in_array($request->ip(), $token->ip_addresses))) { + abort(403, 'You do not have permission to access this resource.'); + } + + $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 0000000..a0b7f58 --- /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 0000000..93ad032 --- /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 0000000..b0baa94 --- /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 0000000..0d158b6 --- /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 0000000..eaa1e78 --- /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 0000000..cb1120c --- /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/InvoiceItems/CreateInvoiceItemRequest.php b/app/Http/Requests/Api/Admin/InvoiceItems/CreateInvoiceItemRequest.php new file mode 100644 index 0000000..c534995 --- /dev/null +++ b/app/Http/Requests/Api/Admin/InvoiceItems/CreateInvoiceItemRequest.php @@ -0,0 +1,51 @@ + 'required|exists:invoices,id', + 'description' => 'required|string|max:255', + 'price' => 'required|numeric|min:0', + 'quantity' => 'required|integer|min:1', + 'reference_type' => 'nullable|string|max:255', + 'reference_id' => [ + 'nullable', + 'integer', + // Ensure reference_id is provided if reference_type is provided + function ($attribute, $value, $fail) { + if ($this->input('reference_type') && !$value) { + $fail('The reference_id field is required when reference_type is provided.'); + } + if ($this->input('reference_type') && $value) { + // Check if the reference_type is a valid class + if (!class_exists($this->input('reference_type'))) { + $fail('The reference_type must be a valid class name.'); + } else { + // Check if the reference_id exists in the specified reference_type table + $modelClass = $this->input('reference_type'); + if (!app($modelClass)->where('id', $value)->exists()) { + $fail('The selected reference_id is invalid for the given reference_type.'); + } + } + } + }, + ], + + ]; + } + + public function prepareForValidation() + { + $this->mergeIfMissing([ + 'status' => 'pending', // Default status to 'pending' if not provided + ]); + } +} diff --git a/app/Http/Requests/Api/Admin/InvoiceItems/DeleteInvoiceItemRequest.php b/app/Http/Requests/Api/Admin/InvoiceItems/DeleteInvoiceItemRequest.php new file mode 100644 index 0000000..afb6c25 --- /dev/null +++ b/app/Http/Requests/Api/Admin/InvoiceItems/DeleteInvoiceItemRequest.php @@ -0,0 +1,10 @@ + 'sometimes|string|max:255', + 'quantity' => 'sometimes|integer|min:1', + 'price' => 'sometimes|numeric|min:0', + 'reference_type' => 'sometimes|string|max:100', + 'reference_id' => [ + 'sometimes', + 'integer', + function ($attribute, $value, $fail) { + if ($this->input('reference_type') && !$value) { + $fail('The reference_id field is required when reference_type is provided.'); + } + if ($this->input('reference_type') && $value) { + // Check if the reference_type is a valid class + if (!class_exists($this->input('reference_type'))) { + $fail('The reference_type must be a valid class name.'); + } else { + // Check if the reference_id exists in the specified reference_type table + $modelClass = $this->input('reference_type'); + if (!app($modelClass)->where('id', $value)->exists()) { + $fail('The selected reference_id is invalid for the given reference_type.'); + } + } + } + }, + ], + ]; + } +} 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 0000000..76b64f4 --- /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 0000000..fe48270 --- /dev/null +++ b/app/Http/Requests/Api/Admin/Invoices/DeleteInvoiceRequest.php @@ -0,0 +1,10 @@ + 'sometimes|required|exists:users,id', + /** + * @example USD + */ + 'currency_code' => 'sometimes|required|string|exists:currencies,code', + 'due_at' => 'sometimes|nullable|date', + /** + * @default pending + */ + 'status' => 'sometimes|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 0000000..711e3f4 --- /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 0000000..e2fd549 --- /dev/null +++ b/app/Http/Requests/Api/Admin/Orders/DeleteOrderRequest.php @@ -0,0 +1,10 @@ + 'sometimes|required|exists:users,id', + /** + * @example USD + */ + 'currency_code' => 'sometimes|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 0000000..0512262 --- /dev/null +++ b/app/Http/Requests/Api/Admin/Services/CreateServiceRequest.php @@ -0,0 +1,56 @@ + 'required|exists:products,id', + 'plan_id' => [ + 'required', + 'exists:plans,id', + function ($attribute, $value, $fail) { + $productId = $this->input('product_id'); + if ($productId && !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 0000000..859d202 --- /dev/null +++ b/app/Http/Requests/Api/Admin/Services/DeleteServiceRequest.php @@ -0,0 +1,10 @@ + 'sometimes|required|exists:products,id', + 'plan_id' => [ + 'sometimes', + 'required', + 'exists:plans,id', + function ($attribute, $value, $fail) { + $productId = $this->input('product_id'); + if ($productId && !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' => 'sometimes|required|exists:users,id', + /** + * @default 1 + */ + 'quantity' => 'sometimes|required|integer|min:1', + /** + * @default pending + */ + 'status' => 'sometimes|required|in:pending,active,cancelled,suspended', + 'expires_at' => 'sometimes|nullable|date|after_or_equal:today', + /** + * @example USD + */ + 'currency_code' => 'sometimes|required|string|exists:currencies,code', + 'price' => 'sometimes|required|numeric|min:0', + 'coupon_id' => 'sometimes|nullable|exists:coupons,id', + 'subscription_id' => 'sometimes|nullable|string|max:255', + 'order_id' => 'sometimes|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 0000000..7402d61 --- /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 0000000..8eb7950 --- /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 0000000..9eee623 --- /dev/null +++ b/app/Http/Requests/Api/Admin/Tickets/DeleteTicketRequest.php @@ -0,0 +1,10 @@ + 'sometimes|required|string|max:255', + 'user_id' => 'sometimes|required|exists:users,id', + 'department' => 'sometimes|nullable|string|in:' . implode(',', config('settings.ticket_departments', [])), + 'priority' => 'sometimes|required|string|in:low,medium,high', + 'status' => 'sometimes|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 0000000..570c593 --- /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 0000000..099e5b9 --- /dev/null +++ b/app/Http/Requests/Api/Admin/Users/DeleteUserRequest.php @@ -0,0 +1,10 @@ + 'sometimes|nullable|string|max:255', + 'last_name' => 'sometimes|nullable|string|max:255', + 'email' => 'sometimes|required|email|max:255|unique:users,email,' . $this->route()->parameter('user')->id, + 'password' => 'sometimes|string|min:8', + 'email_verified_at' => 'sometimes|nullable|date', + 'role_id' => 'sometimes|nullable|exists:roles,id', + ]; + } +} diff --git a/app/Http/Resources/CategoryResource.php b/app/Http/Resources/CategoryResource.php new file mode 100644 index 0000000..c862299 --- /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 0000000..f6098fb --- /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 0000000..5450eb4 --- /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 0000000..3750b09 --- /dev/null +++ b/app/Http/Resources/InvoiceItemResource.php @@ -0,0 +1,37 @@ + function () { + if ($this->reference_type === 'App\Models\Credit') { + return new CreditResource($this->reference); + } elseif ($this->reference_type === 'App\Models\ServiceUpgrade') { + return new ServiceUpgradeResource($this->reference); + } + + return new ServiceResource($this->reference); + }, + 'invoice' => $this->whenLoaded('invoice', function () { + return new InvoiceResource($this->invoice); + }), + ]; + } +} diff --git a/app/Http/Resources/InvoiceResource.php b/app/Http/Resources/InvoiceResource.php new file mode 100644 index 0000000..387be95 --- /dev/null +++ b/app/Http/Resources/InvoiceResource.php @@ -0,0 +1,26 @@ + UserResource::class, + 'items' => InvoiceItemResource::class, + ]; +} diff --git a/app/Http/Resources/OrderResource.php b/app/Http/Resources/OrderResource.php new file mode 100644 index 0000000..da81416 --- /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 0000000..fab10ac --- /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 0000000..6aa2b68 --- /dev/null +++ b/app/Http/Resources/PropertyResource.php @@ -0,0 +1,17 @@ + CouponResource::class, + 'user' => UserResource::class, + 'order' => OrderResource::class, + 'product' => ProductResource::class, + 'invoices' => InvoiceResource::class, + ]; +} diff --git a/app/Http/Resources/ServiceUpgradeResource.php b/app/Http/Resources/ServiceUpgradeResource.php new file mode 100644 index 0000000..b1b843b --- /dev/null +++ b/app/Http/Resources/ServiceUpgradeResource.php @@ -0,0 +1,23 @@ + PropertyResource::class, + 'invoice' => InvoiceResource::class, + ]; +} diff --git a/app/Http/Resources/TicketMessageResource.php b/app/Http/Resources/TicketMessageResource.php new file mode 100644 index 0000000..9e3392f --- /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 0000000..9ba5fc7 --- /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 0000000..ac7c17f --- /dev/null +++ b/app/Http/Resources/UserResource.php @@ -0,0 +1,30 @@ + 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 0000000..4770a8a --- /dev/null +++ b/app/Jobs/Server/CreateJob.php @@ -0,0 +1,44 @@ +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 0000000..9e02bd5 --- /dev/null +++ b/app/Jobs/Server/SuspendJob.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::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 0000000..d3f146d --- /dev/null +++ b/app/Jobs/Server/TerminateJob.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::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 0000000..b64c5bf --- /dev/null +++ b/app/Jobs/Server/UnsuspendJob.php @@ -0,0 +1,38 @@ +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 0000000..76f2465 --- /dev/null +++ b/app/Jobs/Server/UpgradeJob.php @@ -0,0 +1,39 @@ +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 0000000..9ea2bf7 --- /dev/null +++ b/app/Listeners/CancellationCreatedListener.php @@ -0,0 +1,31 @@ +cancellation->type == 'immediate') { + TerminateJob::dispatch($event->cancellation->service); + + $event->cancellation->service->update([ + 'status' => 'cancelled', + ]); + + $event->cancellation->service->invoices()->where('status', 'pending')->update(['status' => 'cancelled']); + + if ($event->cancellation->service->product->stock) { + $event->cancellation->service->product->increment('stock', $event->cancellation->service->quantity); + } + } + // 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 0000000..c6c249e --- /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 0000000..276fc01 --- /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 0000000..dd00560 --- /dev/null +++ b/app/Listeners/InvoicePaidListener.php @@ -0,0 +1,62 @@ + 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 || !($serviceUpgrade instanceof ServiceUpgrade)) { + return; + } + + // Handle the upgrade + (new ServiceUpgradeService)->handle($serviceUpgrade); + } 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 0000000..b71a819 --- /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 0000000..711ba52 --- /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 0000000..49a0cda --- /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 0000000..9ad1323 --- /dev/null +++ b/app/Listeners/TicketMessageCreatedListener.php @@ -0,0 +1,25 @@ +ticketMessage->ticket->user->id !== $event->ticketMessage->user->id) { + // Update ticket status + $event->ticketMessage->ticket->update(['status' => 'replied']); + // Send notification to ticket owner + NotificationHelper::ticketMessageNotification($event->ticketMessage->ticket->user, $event->ticketMessage); + } else { + // Update ticket status + $event->ticketMessage->ticket->update(['status' => 'open']); + } + } +} diff --git a/app/Listeners/TicketReplyEmailListener.php b/app/Listeners/TicketReplyEmailListener.php new file mode 100644 index 0000000..0a928d4 --- /dev/null +++ b/app/Listeners/TicketReplyEmailListener.php @@ -0,0 +1,37 @@ +data['emailTemplate']->key === 'new_ticket_message') { + $message = $event->message; + $host = config('app.url'); + // Only hostname without scheme or link + $host = parse_url($host, PHP_URL_HOST); + + $ticketMessage = $event->data['ticketMessage']; + $ticket = $ticketMessage->ticket; + // Check if we ever sent a reply to this ticket + // Second last, because last is the current message + $previousReply = $ticket->messages()->where('user_id', '!=', $ticket->user_id)->orderBy('id', 'desc')->skip(1)->first(); + $message->getHeaders()->addHeader('Message-ID', $ticketMessage->id . '@' . $host); + if ($previousReply) { + $message->getHeaders()->addHeader('In-Reply-To', $previousReply->id . '@' . $host); + } + $message->getHeaders()->addHeader('References', $ticket->id . '@' . $host); + + // Update reply to + if (config('settings.ticket_mail_piping', false)) { + $message->replyTo(config('settings.ticket_mail_email')); + } + } + } +} diff --git a/app/Livewire/Auth/Login.php b/app/Livewire/Auth/Login.php new file mode 100644 index 0000000..d3b3a99 --- /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 0000000..e7c5be6 --- /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 0000000..6642f6e --- /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 0000000..3734735 --- /dev/null +++ b/app/Livewire/Auth/Register.php @@ -0,0 +1,74 @@ +initializeProperties(null, User::class); + } + + public function rules() + { + $rules = [ + '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', + ]; + + if (config('settings.tos')) { + $rules['tos'] = 'accepted'; + } + + return array_merge($rules, $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 0000000..6de5caf --- /dev/null +++ b/app/Livewire/Auth/Tfa.php @@ -0,0 +1,75 @@ +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 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 0000000..facfb13 --- /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 0000000..d7b40b8 --- /dev/null +++ b/app/Livewire/Cart.php @@ -0,0 +1,300 @@ +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($this->total->price, $this->total->currency->code, 'cart', ClassesCart::get()); + 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 0000000..f905ede --- /dev/null +++ b/app/Livewire/Client/Account.php @@ -0,0 +1,63 @@ +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 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 0000000..19833a3 --- /dev/null +++ b/app/Livewire/Client/Credits.php @@ -0,0 +1,124 @@ +route('account'); + } + + $this->amount = config('settings.credits_minimum_deposit'); + $this->currency = session('currency', config('settings.default_currency')); + $this->gateways = ExtensionHelper::getCheckoutGateways($this->amount, $this->currency, 'credits'); + if (count($this->gateways) > 0 && !array_search($this->gateway, array_column($this->gateways, 'id')) !== false) { + $this->gateway = $this->gateways[0]->id; + } + } + + public function updated($variable) + { + if ($variable === 'amount' || $variable === 'currency') { + $this->gateways = ExtensionHelper::getCheckoutGateways($this->amount, $this->currency, '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 0000000..1965654 --- /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 0000000..74a70de --- /dev/null +++ b/app/Livewire/Component.php @@ -0,0 +1,25 @@ +notify($e->getMessage(), 'error'); + $stopPropagation(); + } + } +} diff --git a/app/Livewire/ComponentWithProperties.php b/app/Livewire/ComponentWithProperties.php new file mode 100644 index 0000000..49a6715 --- /dev/null +++ b/app/Livewire/ComponentWithProperties.php @@ -0,0 +1,91 @@ +custom_properties = 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 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(); + if ($custom_property->non_editable && $model->properties->where('key', $key)->first()) { + return null; + } + + return [ + 'key' => $key, + 'value' => $value, + 'model_id' => $model->id, + 'model_type' => $model->getMorphClass(), + 'name' => $custom_property->name, + 'custom_property_id' => $custom_property->id, + ]; + })->filter()->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 0000000..c5bf187 --- /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 0000000..b057b19 --- /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->name, + ])->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 0000000..db79e97 --- /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 0000000..df3715d --- /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 0000000..ae135b4 --- /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 0000000..c9be4df --- /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 0000000..fd7c1cc --- /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->total, $this->invoice->currency_code, 'invoice', $this->invoice->items); + 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 0000000..e37ba4e --- /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 0000000..e619390 --- /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 0000000..eeb8d1d --- /dev/null +++ b/app/Livewire/Products/Index.php @@ -0,0 +1,44 @@ +products = $this->category->products()->where('hidden', false)->with(['category', 'plans.prices', 'configOptions.children.plans.prices'])->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 0000000..05f800e --- /dev/null +++ b/app/Livewire/Products/Show.php @@ -0,0 +1,27 @@ +product = $this->category->products()->where('slug', $product)->where('hidden', false)->with(['plans.prices', 'configOptions.children.plans.prices'])->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 0000000..c62e580 --- /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', true); + + $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 0000000..74b3f9e --- /dev/null +++ b/app/Livewire/Services/Index.php @@ -0,0 +1,30 @@ +services()->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 0000000..b1addd3 --- /dev/null +++ b/app/Livewire/Services/Show.php @@ -0,0 +1,117 @@ +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; + } elseif ($action['type'] == 'text') { + $this->fields[] = $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 0000000..3f33cfd --- /dev/null +++ b/app/Livewire/Services/Upgrade.php @@ -0,0 +1,247 @@ +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', true); + + 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) { + (new ServiceUpgradeService)->handle($upgrade); + + if (!config('settings.credits_on_downgrade', true)) { + $this->notify('The upgrade has been completed.', 'success', true); + + return $this->redirect(route('services.show', $this->service), true); + } + + // Check if user has credits in this currency + /** @var 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' => abs($price->price), + ]); + } + + if ($price->price < 0) { + $this->notify('The upgrade has been completed. We\'ve added the remaining amount to your account balance.', 'success', true); + } else { + $this->notify('The upgrade has been completed.', 'success', true); + } + + 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', true); + + 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 0000000..db4dbb7 --- /dev/null +++ b/app/Livewire/Services/Widget.php @@ -0,0 +1,31 @@ +services(); + + if ($this->status) { + $query->where('status', $this->status); + } else { + $query->where('status', '!=', 'cancelled'); + } + + return view('services.widget', [ + 'services' => $query->paginate(config('settings.pagination')), + ])->layoutData([ + 'title' => 'Services', + ]); + } +} diff --git a/app/Livewire/Tickets/Create.php b/app/Livewire/Tickets/Create.php new file mode 100644 index 0000000..5fd8258 --- /dev/null +++ b/app/Livewire/Tickets/Create.php @@ -0,0 +1,99 @@ +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', + 'attachments.*' => 'file|max:10240', + ]); + + 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, + ]); + + $message = $ticket->messages()->create([ + 'user_id' => Auth::id(), + 'message' => $this->message, + ]); + + foreach ($this->attachments as $attachment) { + $newName = Str::ulid() . '.' . $attachment->getClientOriginalExtension(); + $path = 'tickets/uploads/' . $newName; + $attachment->storeAs('tickets/uploads', $newName); + + $message->attachments()->create([ + 'path' => $path, + 'filename' => $attachment->getClientOriginalName(), + 'mime_type' => File::mimeType(storage_path('app/' . $path)), + 'filesize' => File::size(storage_path('app/' . $path)), + ]); + } + + $this->notify('Message sent successfully', redirect: true); + + $this->reset(['attachments', 'message', 'subject', 'department', 'service', 'priority']); + $this->dispatch('saved'); + + $this->redirect(route('tickets.show', $ticket), true); + } + + public function render() + { + /** @var 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 0000000..5b509ea --- /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 0000000..c369726 --- /dev/null +++ b/app/Livewire/Tickets/Show.php @@ -0,0 +1,65 @@ + 'file|max:10240'])] + public array $attachments = []; + + #[Rule('required', 'string')] + public string $message; + + public function save() + { + $this->validate(); + + $message = $this->ticket->messages()->create([ + 'user_id' => Auth::id(), + 'message' => $this->message, + ]); + + foreach ($this->attachments as $attachment) { + $newName = Str::ulid() . '.' . $attachment->getClientOriginalExtension(); + $path = 'tickets/uploads/' . $newName; + $attachment->storeAs('tickets/uploads', $newName); + + $message->attachments()->create([ + 'path' => $path, + 'filename' => $attachment->getClientOriginalName(), + 'mime_type' => File::mimeType(storage_path('app/' . $path)), + 'filesize' => File::size(storage_path('app/' . $path)), + ]); + } + + $this->notify('Message sent successfully'); + + $this->message = ''; + $this->attachments = []; + $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 0000000..8f9bd67 --- /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 0000000..714ed88 --- /dev/null +++ b/app/Livewire/Traits/Disabled.php @@ -0,0 +1,26 @@ +getAttributes(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 0000000..ac12b06 --- /dev/null +++ b/app/Livewire/Traits/HasNotifications.php @@ -0,0 +1,35 @@ +notify( + Session::get('notification')['message'], + Session::get('notification')['type'] + ); + Session::forget('notification'); + } + } + + /** + * Notifications + */ + public function notify($message, $type = 'success', $redirect = false) + { + if ($redirect) { + // Set notification in session before redirect + Session::put('notification', [ + 'message' => $message, + 'type' => $type, + ]); + } else { + $this->dispatch('notify', ['message' => $message, 'type' => $type]); + } + } +} diff --git a/app/Mail/Mail.php b/app/Mail/Mail.php new file mode 100644 index 0000000..799230a --- /dev/null +++ b/app/Mail/Mail.php @@ -0,0 +1,55 @@ +emailTemplate = $emailTemplate; + $this->data = $data; + $this->data['body'] = $this->emailTemplate->body; + } + + /** + * Get the message envelope. + */ + public function envelope(): Envelope + { + return new Envelope( + subject: BladeCompiler::render($this->emailTemplate->subject, $this->data), + ); + } + + /** + * 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 0000000..ddbb77f --- /dev/null +++ b/app/Models/ApiKey.php @@ -0,0 +1,31 @@ + 'array', + 'ip_addresses' => 'array', + 'last_used_at' => 'datetime', + ]; + + protected $auditExclude = [ + 'last_used_at', + ]; +} diff --git a/app/Models/Audit.php b/app/Models/Audit.php new file mode 100644 index 0000000..fd567d5 --- /dev/null +++ b/app/Models/Audit.php @@ -0,0 +1,10 @@ +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'); + } + + protected $auditExclude = [ + 'remember_token', + ]; +} diff --git a/app/Models/ConfigOption.php b/app/Models/ConfigOption.php new file mode 100644 index 0000000..db78dde --- /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 0000000..390a518 --- /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 0000000..e1a6b5c --- /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 0000000..0c1e3aa --- /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 0000000..49580d6 --- /dev/null +++ b/app/Models/Credit.php @@ -0,0 +1,34 @@ +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 0000000..459ac54 --- /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 0000000..f9a1d29 --- /dev/null +++ b/app/Models/DebugLog.php @@ -0,0 +1,15 @@ + 'json', + ]; +} diff --git a/app/Models/EmailLog.php b/app/Models/EmailLog.php new file mode 100644 index 0000000..2e1da42 --- /dev/null +++ b/app/Models/EmailLog.php @@ -0,0 +1,21 @@ + 'boolean', + 'cc' => 'array', + 'bcc' => 'array', + ]; +} diff --git a/app/Models/Extension.php b/app/Models/Extension.php new file mode 100644 index 0000000..12db6d6 --- /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 0000000..8df728f --- /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 0000000..ff6cf72 --- /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 0000000..6f37631 --- /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 0000000..4cc01ef --- /dev/null +++ b/app/Models/InvoiceItem.php @@ -0,0 +1,64 @@ + 'decimal:2', + ]; + + public function invoice() + { + return $this->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 0000000..88e01ec --- /dev/null +++ b/app/Models/InvoiceTransaction.php @@ -0,0 +1,59 @@ + 'decimal:2', + 'fee' => 'decimal:2', + ]; + + public function invoice() + { + return $this->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/Model.php b/app/Models/Model.php new file mode 100644 index 0000000..89a8b12 --- /dev/null +++ b/app/Models/Model.php @@ -0,0 +1,5 @@ +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 0000000..57eda97 --- /dev/null +++ b/app/Models/Plan.php @@ -0,0 +1,84 @@ + 'integer', + ]; + + /** + * Get the available prices of the plan. + */ + public function prices() + { + return $this->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 0000000..780ebdd --- /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 0000000..3b36277 --- /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('config_options.hidden', false)->orderBy('config_options.sort', 'asc')->orderBy('config_options.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('config_options.hidden', false)->where('config_options.upgradable', true)->orderBy('config_options.sort', 'asc')->orderBy('config_options.id', 'desc'); + } +} diff --git a/app/Models/ProductUpgrade.php b/app/Models/ProductUpgrade.php new file mode 100644 index 0000000..46a7c68 --- /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 0000000..82a23a1 --- /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 0000000..a9cf364 --- /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 0000000..e2a406b --- /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 0000000..90088fa --- /dev/null +++ b/app/Models/Service.php @@ -0,0 +1,218 @@ + '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); + } + + /** + * 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) { + // Check stock + if ($product->stock !== null && ($product->stock - $this->quantity) < 0) { + return null; + } + $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 0000000..21392ea --- /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 0000000..a2a3e06 --- /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 0000000..9c50611 --- /dev/null +++ b/app/Models/ServiceUpgrade.php @@ -0,0 +1,112 @@ +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 + if ($this->service->expires_at) { + $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; + } else { + $total = $newItem->price(null, $this->service->plan->billing_period, $this->service->plan->billing_unit, $this->service->currency_code)->price; + } + + 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 0000000..9bdea1c --- /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 0000000..4eb5c98 --- /dev/null +++ b/app/Models/Setting.php @@ -0,0 +1,48 @@ + 'boolean', + ]; + + protected $dispatchesEvents = [ + 'retrieved' => Retrieved::class, + 'saving' => Saving::class, + 'saved' => Saved::class, + ]; + + public function getAttributeModifiers(): array + { + if (!$this->encrypted) { + return []; + } + + return [ + 'value' => RightRedactor::class, + ]; + } +} diff --git a/app/Models/TaxRate.php b/app/Models/TaxRate.php new file mode 100644 index 0000000..1680825 --- /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/TicketMailLog.php b/app/Models/TicketMailLog.php new file mode 100644 index 0000000..b0a1def --- /dev/null +++ b/app/Models/TicketMailLog.php @@ -0,0 +1,15 @@ +belongsTo(Ticket::class); + } + + public function user() + { + return $this->belongsTo(User::class); + } + + public function attachments() + { + return $this->hasMany(TicketMessageAttachment::class); + } + + public function ticketMailLog() + { + return $this->belongsTo(TicketMailLog::class); + } +} diff --git a/app/Models/TicketMessageAttachment.php b/app/Models/TicketMessageAttachment.php new file mode 100644 index 0000000..25e1b2f --- /dev/null +++ b/app/Models/TicketMessageAttachment.php @@ -0,0 +1,39 @@ +uuid = (string) \Illuminate\Support\Str::uuid(); + }); + } + + public function ticketMessage() + { + return $this->belongsTo(TicketMessage::class); + } + + public function getLocalPathAttribute(): string + { + return storage_path("app/{$this->path}"); + } + + // Function to check if attachment can be previewed + public function canPreview(): bool + { + return Str::startsWith($this->mime_type, ['image/']); + } +} diff --git a/app/Models/Traits/Auditable.php b/app/Models/Traits/Auditable.php new file mode 100644 index 0000000..2d8cb76 --- /dev/null +++ b/app/Models/Traits/Auditable.php @@ -0,0 +1,25 @@ +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(currency: $currency)->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 0000000..5edef47 --- /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 0000000..dc2ea43 --- /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 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 0000000..b65b858 --- /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 0000000..ac17cac --- /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 0000000..136adea --- /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 0000000..53678d1 --- /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/AuditPolicy.php b/app/Policies/AuditPolicy.php new file mode 100644 index 0000000..8745efb --- /dev/null +++ b/app/Policies/AuditPolicy.php @@ -0,0 +1,25 @@ +hasPermission('admin.audit.viewAny'); + } + + /** + * Determine whether the user can view the model. + */ + public function view(User $user, Audit $role): bool + { + return $user->hasPermission('admin.audit.viewAny'); + } +} diff --git a/app/Policies/BasePolicy.php b/app/Policies/BasePolicy.php new file mode 100644 index 0000000..28db55c --- /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 0000000..e14e20d --- /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 0000000..b6553e9 --- /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 0000000..5276998 --- /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 0000000..b9bcc80 --- /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 0000000..f0c0f78 --- /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 0000000..3424ceb --- /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 0000000..555f946 --- /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 0000000..039be9d --- /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 0000000..1f27e98 --- /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 0000000..23ca258 --- /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 0000000..3b52315 --- /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 0000000..e36b054 --- /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 0000000..efbdabd --- /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 0000000..13e88ac --- /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 0000000..56680b3 --- /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 0000000..0141d5e --- /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 0000000..1107b11 --- /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 0000000..88c1196 --- /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 0000000..96d032a --- /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/TicketMessageAttachmentPolicy.php b/app/Policies/TicketMessageAttachmentPolicy.php new file mode 100644 index 0000000..ce822d8 --- /dev/null +++ b/app/Policies/TicketMessageAttachmentPolicy.php @@ -0,0 +1,17 @@ +hasPermission('admin.tickets.view') || $user->id === $attachment->ticketMessage->ticket->user_id; + } +} diff --git a/app/Policies/TicketMessagePolicy.php b/app/Policies/TicketMessagePolicy.php new file mode 100644 index 0000000..d2c5e91 --- /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 0000000..06c7a32 --- /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 $this->adminPermission($user, '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 $this->adminPermission($user, '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 0000000..d22d3f8 --- /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 0000000..4151a79 --- /dev/null +++ b/app/Providers/AppServiceProvider.php @@ -0,0 +1,196 @@ +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); + }); + + Request::macro('livewireUrl', function () { + if (request()->route()->named('paymenter.livewire.update')) { + $previousUrl = url()->previous(); + + return $previousUrl !== null ? $previousUrl : null; + } + + return request()->fullUrl(); + }); + + Request::macro('livewireRoute', function () { + // Return name of current route + if (request()->route()->named('paymenter.livewire.update')) { + $previousUrl = url()->previous(); + + if ($previousUrl !== null) { + $previousRequest = \Route::getRoutes()->match(request()->create($previousUrl)); + if ($previousRequest) { + return $previousRequest->getName(); + } + } + + return 'paymenter.livewire.update'; + } + + return request()->route()->getName(); + }); + } + + /** + * Bootstrap any application services. + */ + public function boot(): void + { + // Change livewire url + Livewire::setUpdateRoute(function ($handle) { + return Route::post('/paymenter/update', $handle)->middleware('web')->name('paymenter.'); + }); + Livewire::propertySynthesizer(PriceSynth::class); + + Gate::define('has-permission', function (User $user, string $ability) { + return $user->hasPermission($ability); + }); + + Event::listen(function (SocialiteWasCalled $event) { + $event->extendSocialite('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 0000000..a5020f5 --- /dev/null +++ b/app/Providers/Filament/AdminPanelProvider.php @@ -0,0 +1,109 @@ +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([ + 'exit_admin' => MenuItem::make() + ->label('Exit Admin') + ->url('/') + ->icon('heroicon-s-arrow-uturn-left'), + 'logout' => Action::make('logout') + ->label('Sign out') + ->icon(FilamentIcon::resolve(PanelsIconAlias::USER_MENU_LOGOUT_BUTTON) ?? Heroicon::ArrowLeftOnRectangle) + ->url(fn () => $panel->getLogoutUrl()) + ->postToUrl(), + ]) + ->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, + ]) + ->viteTheme('resources/css/filament/admin/theme.css', 'default') + ->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 0000000..d424952 --- /dev/null +++ b/app/Providers/SettingsProvider.php @@ -0,0 +1,80 @@ +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 (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/Redactors/RightRedactor.php b/app/Redactors/RightRedactor.php new file mode 100644 index 0000000..81f154e --- /dev/null +++ b/app/Redactors/RightRedactor.php @@ -0,0 +1,28 @@ + $tenth) ? ($total - $tenth) : 1; + + return str_pad(substr($value, 0, -$length), $total, '#', STR_PAD_RIGHT); + } +} diff --git a/app/Resolvers/UrlResolver.php b/app/Resolvers/UrlResolver.php new file mode 100644 index 0000000..d06db52 --- /dev/null +++ b/app/Resolvers/UrlResolver.php @@ -0,0 +1,22 @@ +has_bits = ($ipv4minbits !== null || $ipv4maxbits !== null || $ipv6minbits !== null || $ipv6maxbits !== null); + } + + /** + * @param string $attribute The attribute being validated + * @param mixed $value The current value of the attribute + * @param Closure $fail Closure to be run in case of failure + */ + public function validate(string $attribute, mixed $value, Closure $fail): void + { + if ($this->allowWildCard && $value === '*') { + return; + } + $mask = null; + $valid_mask = true; + + if (str_contains($value, '/')) { + [$value, $mask] = explode('/', $value); + } elseif ($this->has_bits) { + // if we specify a bit constraint, assume the bits are required + $fail($this->message()); + } + + if (str_contains($value, ':')) { + // ipv6 + if ($mask !== null) { + $valid_mask = filter_var( + $mask, + FILTER_VALIDATE_INT, + ['options' => ['min_range' => $this->ipv6minbits ?? 0, 'max_range' => $this->ipv6maxbits ?? 128]] + ); + } + $valid_address = filter_var($value, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6); + } else { + // ipv4 + if ($mask !== null) { + $valid_mask = filter_var( + $mask, + FILTER_VALIDATE_INT, + ['options' => ['min_range' => $this->ipv4minbits ?? 0, 'max_range' => $this->ipv4maxbits ?? 32]] + ); + if ($valid_mask) { + $long = ip2long($value); + $mask = -1 << (32 - $mask); + $valid_mask = long2ip($long & $mask) === $value; + } + } + $valid_address = filter_var($value, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4); + } + if (!$valid_address || !$valid_mask) { + $fail($this->message()); + } + } + + public function message(): string + { + return !$this->has_bits + ? __('The :attribute must be a valid IP address or CIDR subnet.') + : sprintf( + __('The :attribute must be a valid CIDR subnet with a mask of %d-%d (IPv4) or %d-%d (IPv6) bits.'), + $this->ipv4minbits ?? 0, + $this->ipv4maxbits ?? 32, + $this->ipv6minbits ?? 0, + $this->ipv6maxbits ?? 128 + ); + } +} diff --git a/app/Rules/Domain.php b/app/Rules/Domain.php new file mode 100644 index 0000000..657d19a --- /dev/null +++ b/app/Rules/Domain.php @@ -0,0 +1,16 @@ +unzip($filePath, $extractPath); + + try { + // Define if the folder path is correct or we need to traverse it (based on .php files) + $path = $this->validateExtensionPath($extractPath); + + // Find the php file extending either Extension, Server, or Gateway + $type = $this->getExtensionType($path); + + // Move the files to the correct location + $destinationPath = base_path('extensions/' . ucfirst($type['type']) . 's/' . $type['class']); + $updating = false; + $oldVersion = null; + + // Check if destination directory exists, if so, remove it + if (is_dir($destinationPath)) { + $updating = true; + } + + if ($updating) { + // Read the extension class for current version + $extensionClass = 'Paymenter\\Extensions\\' . ucfirst($type['type']) . 's\\' . ucfirst($type['class']); + if (class_exists($extensionClass)) { + $reflection = new ReflectionClass($extensionClass); + $attributes = $reflection->getAttributes(ExtensionMeta::class); + + if (count($attributes) > 0) { + $extensionMeta = $attributes[0]->newInstance(); + if ($extensionMeta->version) { + $oldVersion = $extensionMeta->version; + } + } + } + File::deleteDirectory($destinationPath); + } + + if (!rename($path, $destinationPath)) { + throw new \Exception('Failed to move the extension files to the destination.'); + } + } catch (\Exception $e) { + // Clean up the extracted files in case of an error + File::deleteDirectory($extractPath); + throw $e; // Re-throw the exception after cleanup + } + + // Remove the extracted files + File::deleteDirectory($extractPath); + + // Execute the upgraded method if it exists + if ($updating) { + Artisan::call(Upgrade::class, [ + 'type' => $type['type'], + 'name' => $type['class'], + 'oldVersion' => $oldVersion, + ]); + } + + return $type['type']; + } + + private function getExtensionType(string $path): array + { + $files = glob($path . '/*.php'); + $type = ['class' => null, 'type' => null]; + foreach ($files as $file) { + // Read file + $content = file_get_contents($file); + if (preg_match('/namespace\s+(.+?);/', $content, $matches)) { + if (!class_exists($matches[1] . '\\' . pathinfo(class_basename($file), PATHINFO_FILENAME))) { + // If the class is not loaded, include the file + require_once $file; // Include the file to load the class + } + $namespace = $matches[1]; + if (preg_match('/^\s*class\s+([A-Za-z_][A-Za-z0-9_]*)\s*(?:extends|implements|\{)/m', $content, $classMatches)) { + $className = $classMatches[1]; + $fullClassName = $namespace . '\\' . $className; + + // Only return className + $type['class'] = $className; + if (is_subclass_of($fullClassName, Server::class)) { + $type['type'] = 'server'; + } elseif (is_subclass_of($fullClassName, Gateway::class)) { + $type['type'] = 'gateway'; + } elseif (is_subclass_of($fullClassName, Extension::class)) { + $type['type'] = 'other'; + } + } + } + } + if (!$type['class'] || !$type['type']) { + throw new \Exception('No valid extension class found in the provided path.'); + } + + return $type; + } + + private function validateExtensionPath(string $path, int $depth = 0): string + { + if ($depth > 1) { + throw new \Exception('Maximum depth reached while validating extension path.'); + } + // Check if the path contains a valid extension structure + $files = glob($path . '/*.php'); + + if (empty($files)) { + // Retry it ONCE with the first subdirectory + $subDirs = glob($path . '/*', GLOB_ONLYDIR); + if (count($subDirs) > 0) { + for ($i = 0; $i < count($subDirs); $i++) { + if (basename($subDirs[$i]) === '__MACOSX') { + continue; + } + + if (glob($subDirs[$i] . '/*.php')) { + $newPath = $subDirs[$i]; + break; + } + } + if (isset($newPath)) { + // Pass the new path with increased depth + return $this->validateExtensionPath($newPath, $depth + 1); + } + } + + throw new \Exception('No valid extension files found in the provided path.'); + } + + // Return the path if it contains valid PHP files + return $path; + } + + private function unzip(string $filePath, string $extractPath) + { + $zip = new \ZipArchive; + if ($zip->open($filePath) === true) { + $zip->extractTo($extractPath); + $zip->close(); + + // Remove the zip file after extraction + File::delete($filePath); + } else { + throw new \Exception('Failed to open the zip file.'); + } + } +} diff --git a/app/Services/ServiceUpgrade/ServiceUpgradeService.php b/app/Services/ServiceUpgrade/ServiceUpgradeService.php new file mode 100644 index 0000000..8920330 --- /dev/null +++ b/app/Services/ServiceUpgrade/ServiceUpgradeService.php @@ -0,0 +1,52 @@ +status = ServiceUpgrade::STATUS_COMPLETED; + $serviceUpgrade->save(); + + // Check if old product stock should be increased + $service = $serviceUpgrade->service; + if ($service->product->stock !== null) { + $serviceUpgrade->service->product->increment('stock', $serviceUpgrade->service->quantity); + } + + $service->plan_id = $serviceUpgrade->plan_id; + $service->product_id = $serviceUpgrade->product_id; + $service->save(); + + $service->refresh(); + + // Decrease stock of new product if applicable + if ($service->product->stock !== null) { + $service->product->decrement('stock', $service->quantity); + } + + 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); + } + }); + } +} diff --git a/app/Support/Passport/ScopeRegistry.php b/app/Support/Passport/ScopeRegistry.php new file mode 100644 index 0000000..e1ff83e --- /dev/null +++ b/app/Support/Passport/ScopeRegistry.php @@ -0,0 +1,29 @@ + '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 0000000..de3f2cb --- /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 0000000..2cedf4f --- /dev/null +++ b/app/View/Components/AppLayout.php @@ -0,0 +1,35 @@ +title = $title; + $this->clients = $clients ? true : false; + $this->description = $description; + $this->image = $image; + } + + /** + * Get the view / contents that represents the component. + * + * @return View + */ + public function render() + { + return view('layouts.app'); + } +} diff --git a/artisan b/artisan new file mode 100644 index 0000000..c35e31d --- /dev/null +++ b/artisan @@ -0,0 +1,18 @@ +#!/usr/bin/env php +handleCommand(new ArgvInput); + +exit($status); diff --git a/bootstrap/app.php b/bootstrap/app.php new file mode 100644 index 0000000..e7b1eb8 --- /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 100644 index 0000000..d6b7ef3 --- /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 0000000..87df203 --- /dev/null +++ b/bootstrap/providers.php @@ -0,0 +1,8 @@ +=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": "chillerlan/php-qrcode", + "version": "5.0.3", + "source": { + "type": "git", + "url": "https://github.com/chillerlan/php-qrcode.git", + "reference": "42e215640e9ebdd857570c9e4e52245d1ee51de2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/chillerlan/php-qrcode/zipball/42e215640e9ebdd857570c9e4e52245d1ee51de2", + "reference": "42e215640e9ebdd857570c9e4e52245d1ee51de2", + "shasum": "" + }, + "require": { + "chillerlan/php-settings-container": "^2.1.6 || ^3.2.1", + "ext-mbstring": "*", + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "chillerlan/php-authenticator": "^4.3.1 || ^5.2.1", + "ext-fileinfo": "*", + "phan/phan": "^5.4.5", + "phpcompatibility/php-compatibility": "10.x-dev", + "phpmd/phpmd": "^2.15", + "phpunit/phpunit": "^9.6", + "setasign/fpdf": "^1.8.2", + "slevomat/coding-standard": "^8.15", + "squizlabs/php_codesniffer": "^3.11" + }, + "suggest": { + "chillerlan/php-authenticator": "Yet another Google authenticator! Also creates URIs for mobile apps.", + "setasign/fpdf": "Required to use the QR FPDF output.", + "simple-icons/simple-icons": "SVG icons that you can use to embed as logos in the QR Code" + }, + "type": "library", + "autoload": { + "psr-4": { + "chillerlan\\QRCode\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT", + "Apache-2.0" + ], + "authors": [ + { + "name": "Kazuhiko Arase", + "homepage": "https://github.com/kazuhikoarase/qrcode-generator" + }, + { + "name": "ZXing Authors", + "homepage": "https://github.com/zxing/zxing" + }, + { + "name": "Ashot Khanamiryan", + "homepage": "https://github.com/khanamiryan/php-qrcode-detector-decoder" + }, + { + "name": "Smiley", + "email": "smiley@chillerlan.net", + "homepage": "https://github.com/codemasher" + }, + { + "name": "Contributors", + "homepage": "https://github.com/chillerlan/php-qrcode/graphs/contributors" + } + ], + "description": "A QR Code generator and reader with a user-friendly API. PHP 7.4+", + "homepage": "https://github.com/chillerlan/php-qrcode", + "keywords": [ + "phpqrcode", + "qr", + "qr code", + "qr-reader", + "qrcode", + "qrcode-generator", + "qrcode-reader" + ], + "support": { + "docs": "https://php-qrcode.readthedocs.io", + "issues": "https://github.com/chillerlan/php-qrcode/issues", + "source": "https://github.com/chillerlan/php-qrcode" + }, + "funding": [ + { + "url": "https://ko-fi.com/codemasher", + "type": "Ko-Fi" + } + ], + "time": "2024-11-21T16:12:34+00:00" + }, + { + "name": "chillerlan/php-settings-container", + "version": "3.2.1", + "source": { + "type": "git", + "url": "https://github.com/chillerlan/php-settings-container.git", + "reference": "95ed3e9676a1d47cab2e3174d19b43f5dbf52681" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/chillerlan/php-settings-container/zipball/95ed3e9676a1d47cab2e3174d19b43f5dbf52681", + "reference": "95ed3e9676a1d47cab2e3174d19b43f5dbf52681", + "shasum": "" + }, + "require": { + "ext-json": "*", + "php": "^8.1" + }, + "require-dev": { + "phpmd/phpmd": "^2.15", + "phpstan/phpstan": "^1.11", + "phpstan/phpstan-deprecation-rules": "^1.2", + "phpunit/phpunit": "^10.5", + "squizlabs/php_codesniffer": "^3.10" + }, + "type": "library", + "autoload": { + "psr-4": { + "chillerlan\\Settings\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Smiley", + "email": "smiley@chillerlan.net", + "homepage": "https://github.com/codemasher" + } + ], + "description": "A container class for immutable settings objects. Not a DI container.", + "homepage": "https://github.com/chillerlan/php-settings-container", + "keywords": [ + "Settings", + "configuration", + "container", + "helper" + ], + "support": { + "issues": "https://github.com/chillerlan/php-settings-container/issues", + "source": "https://github.com/chillerlan/php-settings-container" + }, + "funding": [ + { + "url": "https://www.paypal.com/donate?hosted_button_id=WLYUNAT9ZTJZ4", + "type": "custom" + }, + { + "url": "https://ko-fi.com/codemasher", + "type": "ko_fi" + } + ], + "time": "2024-07-16T11:13:48+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": "directorytree/imapengine", + "version": "v1.16.2", + "source": { + "type": "git", + "url": "https://github.com/DirectoryTree/ImapEngine.git", + "reference": "7616341c38313eaa8b21cf1f084537f4fa5b399f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/DirectoryTree/ImapEngine/zipball/7616341c38313eaa8b21cf1f084537f4fa5b399f", + "reference": "7616341c38313eaa8b21cf1f084537f4fa5b399f", + "shasum": "" + }, + "require": { + "egulias/email-validator": "^4.0", + "illuminate/collections": ">=9.0", + "nesbot/carbon": ">=2.0", + "php": "^8.1", + "symfony/mime": ">=6.0", + "zbateson/mail-mime-parser": "^3.0" + }, + "require-dev": { + "pestphp/pest": "^2.0|^3.0", + "spatie/ray": "^1.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "DirectoryTree\\ImapEngine\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Steve Bauman", + "email": "steven_bauman@outlook.com", + "role": "Developer" + } + ], + "description": "A fully-featured IMAP library -- without the PHP extension", + "homepage": "https://github.com/directorytree/imapengine", + "keywords": [ + "engine", + "imap", + "mail" + ], + "support": { + "issues": "https://github.com/DirectoryTree/ImapEngine/issues", + "source": "https://github.com/DirectoryTree/ImapEngine/tree/v1.16.2" + }, + "funding": [ + { + "url": "https://github.com/stevebauman", + "type": "github" + } + ], + "time": "2025-09-13T17:33:36+00:00" + }, + { + "name": "doctrine/inflector", + "version": "2.1.0", + "source": { + "type": "git", + "url": "https://github.com/doctrine/inflector.git", + "reference": "6d6c96277ea252fc1304627204c3d5e6e15faa3b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/inflector/zipball/6d6c96277ea252fc1304627204c3d5e6e15faa3b", + "reference": "6d6c96277ea252fc1304627204c3d5e6e15faa3b", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "require-dev": { + "doctrine/coding-standard": "^12.0 || ^13.0", + "phpstan/phpstan": "^1.12 || ^2.0", + "phpstan/phpstan-phpunit": "^1.4 || ^2.0", + "phpstan/phpstan-strict-rules": "^1.6 || ^2.0", + "phpunit/phpunit": "^8.5 || ^12.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "Doctrine\\Inflector\\": "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" + }, + { + "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.1.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%2Finflector", + "type": "tidelift" + } + ], + "time": "2025-08-10T19:31:58+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": "v4.0.15", + "source": { + "type": "git", + "url": "https://github.com/filamentphp/actions.git", + "reference": "fd48fa35095a2b5bb0d5264bd132c36a2adc6b09" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/filamentphp/actions/zipball/fd48fa35095a2b5bb0d5264bd132c36a2adc6b09", + "reference": "fd48fa35095a2b5bb0d5264bd132c36a2adc6b09", + "shasum": "" + }, + "require": { + "anourvalar/eloquent-serialize": "^1.2", + "filament/forms": "self.version", + "filament/infolists": "self.version", + "filament/notifications": "self.version", + "filament/support": "self.version", + "league/csv": "^9.16", + "openspout/openspout": "^4.23", + "php": "^8.2" + }, + "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-09-15T11:16:33+00:00" + }, + { + "name": "filament/filament", + "version": "v4.0.15", + "source": { + "type": "git", + "url": "https://github.com/filamentphp/panels.git", + "reference": "18bb60f075a76147e7397b34011160e36fa1e948" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/filamentphp/panels/zipball/18bb60f075a76147e7397b34011160e36fa1e948", + "reference": "18bb60f075a76147e7397b34011160e36fa1e948", + "shasum": "" + }, + "require": { + "chillerlan/php-qrcode": "^5.0", + "filament/actions": "self.version", + "filament/forms": "self.version", + "filament/infolists": "self.version", + "filament/notifications": "self.version", + "filament/schemas": "self.version", + "filament/support": "self.version", + "filament/tables": "self.version", + "filament/widgets": "self.version", + "php": "^8.2", + "pragmarx/google2fa": "^8.0", + "pragmarx/google2fa-qrcode": "^3.0" + }, + "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-09-15T11:16:32+00:00" + }, + { + "name": "filament/forms", + "version": "v4.0.15", + "source": { + "type": "git", + "url": "https://github.com/filamentphp/forms.git", + "reference": "6ac60ebf4321944bbfba91b23c138201986a71b8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/filamentphp/forms/zipball/6ac60ebf4321944bbfba91b23c138201986a71b8", + "reference": "6ac60ebf4321944bbfba91b23c138201986a71b8", + "shasum": "" + }, + "require": { + "danharrin/date-format-converter": "^0.3", + "filament/actions": "self.version", + "filament/schemas": "self.version", + "filament/support": "self.version", + "php": "^8.2", + "ueberdosis/tiptap-php": "^2.0" + }, + "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-09-15T12:50:58+00:00" + }, + { + "name": "filament/infolists", + "version": "v4.0.15", + "source": { + "type": "git", + "url": "https://github.com/filamentphp/infolists.git", + "reference": "881bc4899d87604ffb49ff3611685f520658d469" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/filamentphp/infolists/zipball/881bc4899d87604ffb49ff3611685f520658d469", + "reference": "881bc4899d87604ffb49ff3611685f520658d469", + "shasum": "" + }, + "require": { + "filament/actions": "self.version", + "filament/schemas": "self.version", + "filament/support": "self.version", + "php": "^8.2" + }, + "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-09-11T14:01:29+00:00" + }, + { + "name": "filament/notifications", + "version": "v4.0.15", + "source": { + "type": "git", + "url": "https://github.com/filamentphp/notifications.git", + "reference": "c4792d807c37a2e7640ad7ad9b189fc3e09b3dcb" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/filamentphp/notifications/zipball/c4792d807c37a2e7640ad7ad9b189fc3e09b3dcb", + "reference": "c4792d807c37a2e7640ad7ad9b189fc3e09b3dcb", + "shasum": "" + }, + "require": { + "filament/actions": "self.version", + "filament/support": "self.version", + "php": "^8.2" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Filament\\Notifications\\NotificationsServiceProvider" + ] + } + }, + "autoload": { + "files": [ + "src/Testing/helpers.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-09-09T20:21:33+00:00" + }, + { + "name": "filament/schemas", + "version": "v4.0.15", + "source": { + "type": "git", + "url": "https://github.com/filamentphp/schemas.git", + "reference": "a54e9f3386eaba867dfb1274685392ccc86fb620" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/filamentphp/schemas/zipball/a54e9f3386eaba867dfb1274685392ccc86fb620", + "reference": "a54e9f3386eaba867dfb1274685392ccc86fb620", + "shasum": "" + }, + "require": { + "danharrin/date-format-converter": "^0.3", + "filament/actions": "self.version", + "filament/support": "self.version", + "php": "^8.2" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Filament\\Schemas\\SchemasServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Filament\\Schemas\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Easily add beautiful UI 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-09-15T11:19:20+00:00" + }, + { + "name": "filament/support", + "version": "v4.0.15", + "source": { + "type": "git", + "url": "https://github.com/filamentphp/support.git", + "reference": "93a25877d8ee4663132fd3df297f78352e212049" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/filamentphp/support/zipball/93a25877d8ee4663132fd3df297f78352e212049", + "reference": "93a25877d8ee4663132fd3df297f78352e212049", + "shasum": "" + }, + "require": { + "blade-ui-kit/blade-heroicons": "^2.5", + "danharrin/livewire-rate-limiting": "^2.0", + "ext-intl": "*", + "illuminate/contracts": "^11.28|^12.0", + "kirschbaum-development/eloquent-power-joins": "^4.0", + "league/uri-components": "^7.0", + "livewire/livewire": "^3.5", + "nette/php-generator": "^4.0", + "php": "^8.2", + "ryangjchandler/blade-capture-directive": "^1.0", + "spatie/invade": "^2.0", + "spatie/laravel-package-tools": "^1.9", + "symfony/console": "^7.0", + "symfony/html-sanitizer": "^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-09-15T11:16:50+00:00" + }, + { + "name": "filament/tables", + "version": "v4.0.15", + "source": { + "type": "git", + "url": "https://github.com/filamentphp/tables.git", + "reference": "dfd85c5bad70f0819a44f14ee2d1a6a3da442cc4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/filamentphp/tables/zipball/dfd85c5bad70f0819a44f14ee2d1a6a3da442cc4", + "reference": "dfd85c5bad70f0819a44f14ee2d1a6a3da442cc4", + "shasum": "" + }, + "require": { + "filament/actions": "self.version", + "filament/forms": "self.version", + "filament/support": "self.version", + "php": "^8.2" + }, + "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-09-15T11:19:39+00:00" + }, + { + "name": "filament/widgets", + "version": "v4.0.15", + "source": { + "type": "git", + "url": "https://github.com/filamentphp/widgets.git", + "reference": "35e245262210a944a1a1707fb0ec9857362f527e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/filamentphp/widgets/zipball/35e245262210a944a1a1707fb0ec9857362f527e", + "reference": "35e245262210a944a1a1707fb0ec9857362f527e", + "shasum": "" + }, + "require": { + "filament/schemas": "self.version", + "filament/support": "self.version", + "php": "^8.2" + }, + "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-09-12T09:56:30+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.10.0", + "source": { + "type": "git", + "url": "https://github.com/guzzle/guzzle.git", + "reference": "b51ac707cfa420b7bfd4e4d5e510ba8008e822b4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/guzzle/zipball/b51ac707cfa420b7bfd4e4d5e510ba8008e822b4", + "reference": "b51ac707cfa420b7bfd4e4d5e510ba8008e822b4", + "shasum": "" + }, + "require": { + "ext-json": "*", + "guzzlehttp/promises": "^2.3", + "guzzlehttp/psr7": "^2.8", + "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.10.0" + }, + "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-08-23T22:36:01+00:00" + }, + { + "name": "guzzlehttp/promises", + "version": "2.3.0", + "source": { + "type": "git", + "url": "https://github.com/guzzle/promises.git", + "reference": "481557b130ef3790cf82b713667b43030dc9c957" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/promises/zipball/481557b130ef3790cf82b713667b43030dc9c957", + "reference": "481557b130ef3790cf82b713667b43030dc9c957", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "phpunit/phpunit": "^8.5.44 || ^9.6.25" + }, + "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.3.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-08-22T14:34:08+00:00" + }, + { + "name": "guzzlehttp/psr7", + "version": "2.8.0", + "source": { + "type": "git", + "url": "https://github.com/guzzle/psr7.git", + "reference": "21dc724a0583619cd1652f673303492272778051" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/psr7/zipball/21dc724a0583619cd1652f673303492272778051", + "reference": "21dc724a0583619cd1652f673303492272778051", + "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.44 || ^9.6.25" + }, + "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.8.0" + }, + "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-08-23T21:21:41+00:00" + }, + { + "name": "guzzlehttp/uri-template", + "version": "v1.0.5", + "source": { + "type": "git", + "url": "https://github.com/guzzle/uri-template.git", + "reference": "4f4bbd4e7172148801e76e3decc1e559bdee34e1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/uri-template/zipball/4f4bbd4e7172148801e76e3decc1e559bdee34e1", + "reference": "4f4bbd4e7172148801e76e3decc1e559bdee34e1", + "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.44 || ^9.6.25", + "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.5" + }, + "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-08-22T14:27:06+00:00" + }, + { + "name": "kirschbaum-development/eloquent-power-joins", + "version": "4.2.8", + "source": { + "type": "git", + "url": "https://github.com/kirschbaum-development/eloquent-power-joins.git", + "reference": "d67c7e2efa886d2ef8bb29e86c3ddb9438ac6390" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/kirschbaum-development/eloquent-power-joins/zipball/d67c7e2efa886d2ef8bb29e86c3ddb9438ac6390", + "reference": "d67c7e2efa886d2ef8bb29e86c3ddb9438ac6390", + "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.8" + }, + "time": "2025-08-14T18:43:05+00:00" + }, + { + "name": "laravel/framework", + "version": "v12.28.1", + "source": { + "type": "git", + "url": "https://github.com/laravel/framework.git", + "reference": "868c1f2d3dba4df6d21e3a8d818479f094cfd942" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/framework/zipball/868c1f2d3dba4df6d21e3a8d818479f094cfd942", + "reference": "868c1f2d3dba4df6d21e3a8d818479f094cfd942", + "shasum": "" + }, + "require": { + "brick/math": "^0.11|^0.12|^0.13|^0.14", + "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.33", + "symfony/polyfill-php84": "^1.33", + "symfony/polyfill-php85": "^1.33", + "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/json-schema": "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", + "opis/json-schema": "^2.4.1", + "orchestra/testbench-core": "^10.6.5", + "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-09-04T14:58:12+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.23.0", + "source": { + "type": "git", + "url": "https://github.com/laravel/socialite.git", + "reference": "e9e0fc83b9d8d71c8385a5da20e5b95ca6234cf5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/socialite/zipball/e9e0fc83b9d8d71c8385a5da20e5b95ca6234cf5", + "reference": "e9e0fc83b9d8d71c8385a5da20e5b95ca6234cf5", + "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-07-23T14:16:08+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.1", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/commonmark.git", + "reference": "10732241927d3971d28e7ea7b5712721fa2296ca" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/commonmark/zipball/10732241927d3971d28e7ea7b5712721fa2296ca", + "reference": "10732241927d3971d28e7ea7b5712721fa2296ca", + "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 || ^6.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-07-20T12:47:49+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.25.0", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/csv.git", + "reference": "f856f532866369fb1debe4e7c5a1db185f40ef86" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/csv/zipball/f856f532866369fb1debe4e7c5a1db185f40ef86", + "reference": "f856f532866369fb1debe4e7c5a1db185f40ef86", + "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 || ^12.3.6", + "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-09-11T08:29:08+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-components", + "version": "7.5.1", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/uri-components.git", + "reference": "4aabf0e2f2f9421ffcacab35be33e4fb5e63c44f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/uri-components/zipball/4aabf0e2f2f9421ffcacab35be33e4fb5e63c44f", + "reference": "4aabf0e2f2f9421ffcacab35be33e4fb5e63c44f", + "shasum": "" + }, + "require": { + "league/uri": "^7.5", + "php": "^8.1" + }, + "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", + "ext-mbstring": "to use the sorting algorithm of URLSearchParams", + "jeremykendall/php-domain-parser": "to resolve Public Suffix and Top Level Domain", + "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 components manipulation library", + "homepage": "http://uri.thephpleague.com", + "keywords": [ + "authority", + "components", + "fragment", + "host", + "middleware", + "modifier", + "path", + "port", + "query", + "rfc3986", + "scheme", + "uri", + "url", + "userinfo" + ], + "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-components/tree/7.5.1" + }, + "funding": [ + { + "url": "https://github.com/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.10.0", + "source": { + "type": "git", + "url": "https://github.com/Masterminds/html5-php.git", + "reference": "fcf91eb64359852f00d921887b219479b4f21251" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Masterminds/html5-php/zipball/fcf91eb64359852f00d921887b219479b4f21251", + "reference": "fcf91eb64359852f00d921887b219479b4f21251", + "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.10.0" + }, + "time": "2025-07-25T09:04:22+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.3", + "source": { + "type": "git", + "url": "https://github.com/CarbonPHP/carbon.git", + "reference": "8e3643dcd149ae0fe1d2ff4f2c8e4bbfad7c165f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/CarbonPHP/carbon/zipball/8e3643dcd149ae0fe1d2ff4f2c8e4bbfad7c165f", + "reference": "8e3643dcd149ae0fe1d2ff4f2c8e4bbfad7c165f", + "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": "^v3.87.1", + "kylekatarnls/multi-tester": "^2.5.3", + "phpmd/phpmd": "^2.15.0", + "phpstan/extension-installer": "^1.4.3", + "phpstan/phpstan": "^2.1.22", + "phpunit/phpunit": "^10.5.53", + "squizlabs/php_codesniffer": "^3.13.4" + }, + "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-09-06T13:39:36+00:00" + }, + { + "name": "nette/php-generator", + "version": "v4.2.0", + "source": { + "type": "git", + "url": "https://github.com/nette/php-generator.git", + "reference": "4707546a1f11badd72f5d82af4f8a6bc64bd56ac" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nette/php-generator/zipball/4707546a1f11badd72f5d82af4f8a6bc64bd56ac", + "reference": "4707546a1f11badd72f5d82af4f8a6bc64bd56ac", + "shasum": "" + }, + "require": { + "nette/utils": "^4.0.6", + "php": "8.1 - 8.5" + }, + "require-dev": { + "jetbrains/phpstorm-attributes": "^1.2", + "nette/tester": "^2.4", + "nikic/php-parser": "^5.0", + "phpstan/phpstan-nette": "^2.0@stable", + "tracy/tracy": "^2.8" + }, + "suggest": { + "nikic/php-parser": "to use ClassType::from(withBodies: true) & ClassType::fromCode()" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "4.2-dev" + } + }, + "autoload": { + "psr-4": { + "Nette\\": "src" + }, + "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 PHP Generator: generates neat PHP code for you. Supports new PHP 8.5 features.", + "homepage": "https://nette.org", + "keywords": [ + "code", + "nette", + "php", + "scaffolding" + ], + "support": { + "issues": "https://github.com/nette/php-generator/issues", + "source": "https://github.com/nette/php-generator/tree/v4.2.0" + }, + "time": "2025-08-06T18:24:31+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.8", + "source": { + "type": "git", + "url": "https://github.com/nette/utils.git", + "reference": "c930ca4e3cf4f17dcfb03037703679d2396d2ede" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nette/utils/zipball/c930ca4e3cf4f17dcfb03037703679d2396d2ede", + "reference": "c930ca4e3cf4f17dcfb03037703679d2396d2ede", + "shasum": "" + }, + "require": { + "php": "8.0 - 8.5" + }, + "conflict": { + "nette/finder": "<3", + "nette/schema": "<1.2.2" + }, + "require-dev": { + "jetbrains/phpstorm-attributes": "^1.2", + "nette/tester": "^2.5", + "phpstan/phpstan-nette": "^2.0@stable", + "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": { + "psr-4": { + "Nette\\": "src" + }, + "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.8" + }, + "time": "2025-08-06T21:43:34+00:00" + }, + { + "name": "nikic/php-parser", + "version": "v5.6.1", + "source": { + "type": "git", + "url": "https://github.com/nikic/PHP-Parser.git", + "reference": "f103601b29efebd7ff4a1ca7b3eeea9e3336a2a2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/f103601b29efebd7ff4a1ca7b3eeea9e3336a2a2", + "reference": "f103601b29efebd7ff4a1ca7b3eeea9e3336a2a2", + "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.x-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.6.1" + }, + "time": "2025-08-13T20:13:15+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.32.0", + "source": { + "type": "git", + "url": "https://github.com/openspout/openspout.git", + "reference": "41f045c1f632e1474e15d4c7bc3abcb4a153563d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/openspout/openspout/zipball/41f045c1f632e1474e15d4c7bc3abcb4a153563d", + "reference": "41f045c1f632e1474e15d4c7bc3abcb4a153563d", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-fileinfo": "*", + "ext-filter": "*", + "ext-libxml": "*", + "ext-xmlreader": "*", + "ext-zip": "*", + "php": "~8.3.0 || ~8.4.0 || ~8.5.0" + }, + "require-dev": { + "ext-zlib": "*", + "friendsofphp/php-cs-fixer": "^3.86.0", + "infection/infection": "^0.31.2", + "phpbench/phpbench": "^1.4.1", + "phpstan/phpstan": "^2.1.22", + "phpstan/phpstan-phpunit": "^2.0.7", + "phpstan/phpstan-strict-rules": "^2.0.6", + "phpunit/phpunit": "^12.3.7" + }, + "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.32.0" + }, + "funding": [ + { + "url": "https://paypal.me/filippotessarotto", + "type": "custom" + }, + { + "url": "https://github.com/Slamdunk", + "type": "github" + } + ], + "time": "2025-09-03T16:03:54+00:00" + }, + { + "name": "owen-it/laravel-auditing", + "version": "v14.0.0", + "source": { + "type": "git", + "url": "https://github.com/owen-it/laravel-auditing.git", + "reference": "f92602d1b3f53df29ddd577290e9d735ea707c53" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/owen-it/laravel-auditing/zipball/f92602d1b3f53df29ddd577290e9d735ea707c53", + "reference": "f92602d1b3f53df29ddd577290e9d735ea707c53", + "shasum": "" + }, + "require": { + "ext-json": "*", + "illuminate/console": "^11.0|^12.0", + "illuminate/database": "^11.0|^12.0", + "illuminate/filesystem": "^11.0|^12.0", + "php": "^8.2" + }, + "require-dev": { + "mockery/mockery": "^1.5.1", + "orchestra/testbench": "^9.0|^10.0", + "phpunit/phpunit": "^11.0" + }, + "type": "package", + "extra": { + "laravel": { + "providers": [ + "OwenIt\\Auditing\\AuditingServiceProvider" + ] + }, + "branch-alias": { + "dev-master": "v14-dev" + } + }, + "autoload": { + "psr-4": { + "OwenIt\\Auditing\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Antério Vieira", + "email": "anteriovieira@gmail.com" + }, + { + "name": "Raphael França", + "email": "raphaelfrancabsb@gmail.com" + }, + { + "name": "Morten D. Hansen", + "email": "morten@visia.dk" + } + ], + "description": "Audit changes of your Eloquent models in Laravel", + "homepage": "https://laravel-auditing.com", + "keywords": [ + "Accountability", + "Audit", + "auditing", + "changes", + "eloquent", + "history", + "laravel", + "log", + "logging", + "lumen", + "observer", + "record", + "revision", + "tracking" + ], + "support": { + "issues": "https://github.com/owen-it/laravel-auditing/issues", + "source": "https://github.com/owen-it/laravel-auditing" + }, + "time": "2025-02-26T16:40:54+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": "php-di/invoker", + "version": "2.3.7", + "source": { + "type": "git", + "url": "https://github.com/PHP-DI/Invoker.git", + "reference": "3c1ddfdef181431fbc4be83378f6d036d59e81e1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/PHP-DI/Invoker/zipball/3c1ddfdef181431fbc4be83378f6d036d59e81e1", + "reference": "3c1ddfdef181431fbc4be83378f6d036d59e81e1", + "shasum": "" + }, + "require": { + "php": ">=7.3", + "psr/container": "^1.0|^2.0" + }, + "require-dev": { + "athletic/athletic": "~0.1.8", + "mnapoli/hard-mode": "~0.3.0", + "phpunit/phpunit": "^9.0 || ^10 || ^11 || ^12" + }, + "type": "library", + "autoload": { + "psr-4": { + "Invoker\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Generic and extensible callable invoker", + "homepage": "https://github.com/PHP-DI/Invoker", + "keywords": [ + "callable", + "dependency", + "dependency-injection", + "injection", + "invoke", + "invoker" + ], + "support": { + "issues": "https://github.com/PHP-DI/Invoker/issues", + "source": "https://github.com/PHP-DI/Invoker/tree/2.3.7" + }, + "funding": [ + { + "url": "https://github.com/mnapoli", + "type": "github" + } + ], + "time": "2025-08-30T10:22:22+00:00" + }, + { + "name": "php-di/php-di", + "version": "7.1.1", + "source": { + "type": "git", + "url": "https://github.com/PHP-DI/PHP-DI.git", + "reference": "f88054cc052e40dbe7b383c8817c19442d480352" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/PHP-DI/PHP-DI/zipball/f88054cc052e40dbe7b383c8817c19442d480352", + "reference": "f88054cc052e40dbe7b383c8817c19442d480352", + "shasum": "" + }, + "require": { + "laravel/serializable-closure": "^1.0 || ^2.0", + "php": ">=8.0", + "php-di/invoker": "^2.0", + "psr/container": "^1.1 || ^2.0" + }, + "provide": { + "psr/container-implementation": "^1.0" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^3", + "friendsofphp/proxy-manager-lts": "^1", + "mnapoli/phpunit-easymock": "^1.3", + "phpunit/phpunit": "^9.6 || ^10 || ^11", + "vimeo/psalm": "^5|^6" + }, + "suggest": { + "friendsofphp/proxy-manager-lts": "Install it if you want to use lazy injection (version ^1)" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "DI\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "The dependency injection container for humans", + "homepage": "https://php-di.org/", + "keywords": [ + "PSR-11", + "container", + "container-interop", + "dependency injection", + "di", + "ioc", + "psr11" + ], + "support": { + "issues": "https://github.com/PHP-DI/PHP-DI/issues", + "source": "https://github.com/PHP-DI/PHP-DI/tree/7.1.1" + }, + "funding": [ + { + "url": "https://github.com/mnapoli", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/php-di/php-di", + "type": "tidelift" + } + ], + "time": "2025-08-16T11:10:48+00:00" + }, + { + "name": "phpoption/phpoption", + "version": "1.9.4", + "source": { + "type": "git", + "url": "https://github.com/schmittjoh/php-option.git", + "reference": "638a154f8d4ee6a5cfa96d6a34dfbe0cffa9566d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/schmittjoh/php-option/zipball/638a154f8d4ee6a5cfa96d6a34dfbe0cffa9566d", + "reference": "638a154f8d4ee6a5cfa96d6a34dfbe0cffa9566d", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "phpunit/phpunit": "^8.5.44 || ^9.6.25 || ^10.5.53 || ^11.5.34" + }, + "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.4" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpoption/phpoption", + "type": "tidelift" + } + ], + "time": "2025-08-21T11:53:16+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": "pragmarx/google2fa", + "version": "v8.0.3", + "source": { + "type": "git", + "url": "https://github.com/antonioribeiro/google2fa.git", + "reference": "6f8d87ebd5afbf7790bde1ffc7579c7c705e0fad" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/antonioribeiro/google2fa/zipball/6f8d87ebd5afbf7790bde1ffc7579c7c705e0fad", + "reference": "6f8d87ebd5afbf7790bde1ffc7579c7c705e0fad", + "shasum": "" + }, + "require": { + "paragonie/constant_time_encoding": "^1.0|^2.0|^3.0", + "php": "^7.1|^8.0" + }, + "require-dev": { + "phpstan/phpstan": "^1.9", + "phpunit/phpunit": "^7.5.15|^8.5|^9.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "PragmaRX\\Google2FA\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Antonio Carlos Ribeiro", + "email": "acr@antoniocarlosribeiro.com", + "role": "Creator & Designer" + } + ], + "description": "A One Time Password Authentication package, compatible with Google Authenticator.", + "keywords": [ + "2fa", + "Authentication", + "Two Factor Authentication", + "google2fa" + ], + "support": { + "issues": "https://github.com/antonioribeiro/google2fa/issues", + "source": "https://github.com/antonioribeiro/google2fa/tree/v8.0.3" + }, + "time": "2024-09-05T11:56:40+00:00" + }, + { + "name": "pragmarx/google2fa-qrcode", + "version": "v3.0.0", + "source": { + "type": "git", + "url": "https://github.com/antonioribeiro/google2fa-qrcode.git", + "reference": "ce4d8a729b6c93741c607cfb2217acfffb5bf76b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/antonioribeiro/google2fa-qrcode/zipball/ce4d8a729b6c93741c607cfb2217acfffb5bf76b", + "reference": "ce4d8a729b6c93741c607cfb2217acfffb5bf76b", + "shasum": "" + }, + "require": { + "php": ">=7.1", + "pragmarx/google2fa": ">=4.0" + }, + "require-dev": { + "bacon/bacon-qr-code": "^2.0", + "chillerlan/php-qrcode": "^1.0|^2.0|^3.0|^4.0", + "khanamiryan/qrcode-detector-decoder": "^1.0", + "phpunit/phpunit": "~4|~5|~6|~7|~8|~9" + }, + "suggest": { + "bacon/bacon-qr-code": "For QR Code generation, requires imagick", + "chillerlan/php-qrcode": "For QR Code generation" + }, + "type": "library", + "extra": { + "component": "package", + "branch-alias": { + "dev-master": "1.0-dev" + } + }, + "autoload": { + "psr-4": { + "PragmaRX\\Google2FAQRCode\\": "src/", + "PragmaRX\\Google2FAQRCode\\Tests\\": "tests/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Antonio Carlos Ribeiro", + "email": "acr@antoniocarlosribeiro.com", + "role": "Creator & Designer" + } + ], + "description": "QR Code package for Google2FA", + "keywords": [ + "2fa", + "Authentication", + "Two Factor Authentication", + "google2fa", + "qr code", + "qrcode" + ], + "support": { + "issues": "https://github.com/antonioribeiro/google2fa-qrcode/issues", + "source": "https://github.com/antonioribeiro/google2fa-qrcode/tree/v3.0.0" + }, + "time": "2021-08-15T12:53:48+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.10", + "source": { + "type": "git", + "url": "https://github.com/bobthecow/psysh.git", + "reference": "6e80abe6f2257121f1eb9a4c55bf29d921025b22" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/bobthecow/psysh/zipball/6e80abe6f2257121f1eb9a4c55bf29d921025b22", + "reference": "6e80abe6f2257121f1eb9a4c55bf29d921025b22", + "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" + } + ], + "description": "An interactive shell for modern PHP.", + "homepage": "https://psysh.org", + "keywords": [ + "REPL", + "console", + "interactive", + "shell" + ], + "support": { + "issues": "https://github.com/bobthecow/psysh/issues", + "source": "https://github.com/bobthecow/psysh/tree/v0.12.10" + }, + "time": "2025-08-04T12:39:37+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.1", + "source": { + "type": "git", + "url": "https://github.com/ramsey/uuid.git", + "reference": "81f941f6f729b1e3ceea61d9d014f8b6c6800440" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ramsey/uuid/zipball/81f941f6f729b1e3ceea61d9d014f8b6c6800440", + "reference": "81f941f6f729b1e3ceea61d9d014f8b6c6800440", + "shasum": "" + }, + "require": { + "brick/math": "^0.8.8 || ^0.9 || ^0.10 || ^0.11 || ^0.12 || ^0.13 || ^0.14", + "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.1" + }, + "time": "2025-09-04T20:59:21+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": "scrivo/highlight.php", + "version": "v9.18.1.10", + "source": { + "type": "git", + "url": "https://github.com/scrivo/highlight.php.git", + "reference": "850f4b44697a2552e892ffe71490ba2733c2fc6e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/scrivo/highlight.php/zipball/850f4b44697a2552e892ffe71490ba2733c2fc6e", + "reference": "850f4b44697a2552e892ffe71490ba2733c2fc6e", + "shasum": "" + }, + "require": { + "ext-json": "*", + "php": ">=5.4" + }, + "require-dev": { + "phpunit/phpunit": "^4.8|^5.7", + "sabberworm/php-css-parser": "^8.3", + "symfony/finder": "^2.8|^3.4|^5.4", + "symfony/var-dumper": "^2.8|^3.4|^5.4" + }, + "suggest": { + "ext-mbstring": "Allows highlighting code with unicode characters and supports language with unicode keywords" + }, + "type": "library", + "autoload": { + "files": [ + "HighlightUtilities/functions.php" + ], + "psr-0": { + "Highlight\\": "", + "HighlightUtilities\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Geert Bergman", + "homepage": "http://www.scrivo.org/", + "role": "Project Author" + }, + { + "name": "Vladimir Jimenez", + "homepage": "https://allejo.io", + "role": "Maintainer" + }, + { + "name": "Martin Folkers", + "homepage": "https://twobrain.io", + "role": "Contributor" + } + ], + "description": "Server side syntax highlighter that supports 185 languages. It's a PHP port of highlight.js", + "keywords": [ + "code", + "highlight", + "highlight.js", + "highlight.php", + "syntax" + ], + "support": { + "issues": "https://github.com/scrivo/highlight.php/issues", + "source": "https://github.com/scrivo/highlight.php" + }, + "funding": [ + { + "url": "https://github.com/allejo", + "type": "github" + } + ], + "time": "2022-12-17T21:53:22+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-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.5", + "source": { + "type": "git", + "url": "https://github.com/spatie/laravel-query-builder.git", + "reference": "ee3c98235616f88c11e75d3df5ea48dc7b20dd93" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/spatie/laravel-query-builder/zipball/ee3c98235616f88c11e75d3df5ea48dc7b20dd93", + "reference": "ee3c98235616f88c11e75d3df5ea48dc7b20dd93", + "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-08-04T07:36:33+00:00" + }, + { + "name": "spatie/shiki-php", + "version": "2.3.2", + "source": { + "type": "git", + "url": "https://github.com/spatie/shiki-php.git", + "reference": "a2e78a9ff8a1290b25d550be8fbf8285c13175c5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/spatie/shiki-php/zipball/a2e78a9ff8a1290b25d550be8fbf8285c13175c5", + "reference": "a2e78a9ff8a1290b25d550be8fbf8285c13175c5", + "shasum": "" + }, + "require": { + "ext-json": "*", + "php": "^8.0", + "symfony/process": "^5.4|^6.4|^7.1" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^v3.0", + "pestphp/pest": "^1.8", + "phpunit/phpunit": "^9.5", + "spatie/pest-plugin-snapshots": "^1.1", + "spatie/ray": "^1.10" + }, + "type": "library", + "autoload": { + "psr-4": { + "Spatie\\ShikiPhp\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Rias Van der Veken", + "email": "rias@spatie.be", + "role": "Developer" + }, + { + "name": "Freek Van der Herten", + "email": "freek@spatie.be", + "role": "Developer" + } + ], + "description": "Highlight code using Shiki in PHP", + "homepage": "https://github.com/spatie/shiki-php", + "keywords": [ + "shiki", + "spatie" + ], + "support": { + "source": "https://github.com/spatie/shiki-php/tree/2.3.2" + }, + "funding": [ + { + "url": "https://github.com/spatie", + "type": "github" + } + ], + "time": "2025-02-21T14:16:57+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.3", + "source": { + "type": "git", + "url": "https://github.com/symfony/console.git", + "reference": "cb0102a1c5ac3807cf3fdf8bea96007df7fdbea7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/console/zipball/cb0102a1c5ac3807cf3fdf8bea96007df7fdbea7", + "reference": "cb0102a1c5ac3807cf3fdf8bea96007df7fdbea7", + "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.3" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-08-25T06:35:40+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.2", + "source": { + "type": "git", + "url": "https://github.com/symfony/error-handler.git", + "reference": "0b31a944fcd8759ae294da4d2808cbc53aebd0c3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/error-handler/zipball/0b31a944fcd8759ae294da4d2808cbc53aebd0c3", + "reference": "0b31a944fcd8759ae294da4d2808cbc53aebd0c3", + "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.2" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-07-07T08:17:57+00:00" + }, + { + "name": "symfony/event-dispatcher", + "version": "v7.3.3", + "source": { + "type": "git", + "url": "https://github.com/symfony/event-dispatcher.git", + "reference": "b7dc69e71de420ac04bc9ab830cf3ffebba48191" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/b7dc69e71de420ac04bc9ab830cf3ffebba48191", + "reference": "b7dc69e71de420ac04bc9ab830cf3ffebba48191", + "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.3" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-08-13T11:49:31+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.2", + "source": { + "type": "git", + "url": "https://github.com/symfony/finder.git", + "reference": "2a6614966ba1074fa93dae0bc804227422df4dfe" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/finder/zipball/2a6614966ba1074fa93dae0bc804227422df4dfe", + "reference": "2a6614966ba1074fa93dae0bc804227422df4dfe", + "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.2" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-07-15T13:41:35+00:00" + }, + { + "name": "symfony/html-sanitizer", + "version": "v7.3.3", + "source": { + "type": "git", + "url": "https://github.com/symfony/html-sanitizer.git", + "reference": "8740fc48979f649dee8b8fc51a2698e5c190bf12" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/html-sanitizer/zipball/8740fc48979f649dee8b8fc51a2698e5c190bf12", + "reference": "8740fc48979f649dee8b8fc51a2698e5c190bf12", + "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.3" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-08-12T10:34:03+00:00" + }, + { + "name": "symfony/http-foundation", + "version": "v7.3.3", + "source": { + "type": "git", + "url": "https://github.com/symfony/http-foundation.git", + "reference": "7475561ec27020196c49bb7c4f178d33d7d3dc00" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/http-foundation/zipball/7475561ec27020196c49bb7c4f178d33d7d3dc00", + "reference": "7475561ec27020196c49bb7c4f178d33d7d3dc00", + "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.3" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-08-20T08:04:18+00:00" + }, + { + "name": "symfony/http-kernel", + "version": "v7.3.3", + "source": { + "type": "git", + "url": "https://github.com/symfony/http-kernel.git", + "reference": "72c304de37e1a1cec6d5d12b81187ebd4850a17b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/http-kernel/zipball/72c304de37e1a1cec6d5d12b81187ebd4850a17b", + "reference": "72c304de37e1a1cec6d5d12b81187ebd4850a17b", + "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.3" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-08-29T08:23:45+00:00" + }, + { + "name": "symfony/mailer", + "version": "v7.3.3", + "source": { + "type": "git", + "url": "https://github.com/symfony/mailer.git", + "reference": "a32f3f45f1990db8c4341d5122a7d3a381c7e575" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/mailer/zipball/a32f3f45f1990db8c4341d5122a7d3a381c7e575", + "reference": "a32f3f45f1990db8c4341d5122a7d3a381c7e575", + "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.3" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-08-13T11:49:31+00:00" + }, + { + "name": "symfony/mime", + "version": "v7.3.2", + "source": { + "type": "git", + "url": "https://github.com/symfony/mime.git", + "reference": "e0a0f859148daf1edf6c60b398eb40bfc96697d1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/mime/zipball/e0a0f859148daf1edf6c60b398eb40bfc96697d1", + "reference": "e0a0f859148daf1edf6c60b398eb40bfc96697d1", + "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.2" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-07-15T13:41:35+00:00" + }, + { + "name": "symfony/polyfill-ctype", + "version": "v1.33.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.33.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-09-09T11:45:10+00:00" + }, + { + "name": "symfony/polyfill-iconv", + "version": "v1.33.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-iconv.git", + "reference": "5f3b930437ae03ae5dff61269024d8ea1b3774aa" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-iconv/zipball/5f3b930437ae03ae5dff61269024d8ea1b3774aa", + "reference": "5f3b930437ae03ae5dff61269024d8ea1b3774aa", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "provide": { + "ext-iconv": "*" + }, + "suggest": { + "ext-iconv": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Iconv\\": "" + } + }, + "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 Iconv extension", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "iconv", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-iconv/tree/v1.33.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-09-17T14:58:18+00:00" + }, + { + "name": "symfony/polyfill-intl-grapheme", + "version": "v1.33.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-grapheme.git", + "reference": "380872130d3a5dd3ace2f4010d95125fde5d5c70" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/380872130d3a5dd3ace2f4010d95125fde5d5c70", + "reference": "380872130d3a5dd3ace2f4010d95125fde5d5c70", + "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.33.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-06-27T09:58:17+00:00" + }, + { + "name": "symfony/polyfill-intl-idn", + "version": "v1.33.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.33.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-09-10T14:38:51+00:00" + }, + { + "name": "symfony/polyfill-intl-normalizer", + "version": "v1.33.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.33.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-09-09T11:45:10+00:00" + }, + { + "name": "symfony/polyfill-mbstring", + "version": "v1.33.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.33.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-12-23T08:48:59+00:00" + }, + { + "name": "symfony/polyfill-php80", + "version": "v1.33.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.33.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-01-02T08:10:11+00:00" + }, + { + "name": "symfony/polyfill-php83", + "version": "v1.33.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php83.git", + "reference": "17f6f9a6b1735c0f163024d959f700cfbc5155e5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php83/zipball/17f6f9a6b1735c0f163024d959f700cfbc5155e5", + "reference": "17f6f9a6b1735c0f163024d959f700cfbc5155e5", + "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.33.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-07-08T02:45:35+00:00" + }, + { + "name": "symfony/polyfill-php84", + "version": "v1.33.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php84.git", + "reference": "d8ced4d875142b6a7426000426b8abc631d6b191" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php84/zipball/d8ced4d875142b6a7426000426b8abc631d6b191", + "reference": "d8ced4d875142b6a7426000426b8abc631d6b191", + "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\\Php84\\": "" + }, + "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.4+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php84/tree/v1.33.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-06-24T13:30:11+00:00" + }, + { + "name": "symfony/polyfill-php85", + "version": "v1.33.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php85.git", + "reference": "d4e5fcd4ab3d998ab16c0db48e6cbb9a01993f91" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php85/zipball/d4e5fcd4ab3d998ab16c0db48e6cbb9a01993f91", + "reference": "d4e5fcd4ab3d998ab16c0db48e6cbb9a01993f91", + "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\\Php85\\": "" + }, + "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.5+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php85/tree/v1.33.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-06-23T16:12:55+00:00" + }, + { + "name": "symfony/polyfill-uuid", + "version": "v1.33.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.33.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-09-09T11:45:10+00:00" + }, + { + "name": "symfony/process", + "version": "v7.3.3", + "source": { + "type": "git", + "url": "https://github.com/symfony/process.git", + "reference": "32241012d521e2e8a9d713adb0812bb773b907f1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/process/zipball/32241012d521e2e8a9d713adb0812bb773b907f1", + "reference": "32241012d521e2e8a9d713adb0812bb773b907f1", + "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.3" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-08-18T09:42:54+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.2", + "source": { + "type": "git", + "url": "https://github.com/symfony/routing.git", + "reference": "7614b8ca5fa89b9cd233e21b627bfc5774f586e4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/routing/zipball/7614b8ca5fa89b9cd233e21b627bfc5774f586e4", + "reference": "7614b8ca5fa89b9cd233e21b627bfc5774f586e4", + "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.2" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-07-15T11:36:08+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.3", + "source": { + "type": "git", + "url": "https://github.com/symfony/string.git", + "reference": "17a426cce5fd1f0901fefa9b2a490d0038fd3c9c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/string/zipball/17a426cce5fd1f0901fefa9b2a490d0038fd3c9c", + "reference": "17a426cce5fd1f0901fefa9b2a490d0038fd3c9c", + "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.3" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-08-25T06:35:40+00:00" + }, + { + "name": "symfony/translation", + "version": "v7.3.3", + "source": { + "type": "git", + "url": "https://github.com/symfony/translation.git", + "reference": "e0837b4cbcef63c754d89a4806575cada743a38d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/translation/zipball/e0837b4cbcef63c754d89a4806575cada743a38d", + "reference": "e0837b4cbcef63c754d89a4806575cada743a38d", + "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.3" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-08-01T21:02:37+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.3", + "source": { + "type": "git", + "url": "https://github.com/symfony/var-dumper.git", + "reference": "34d8d4c4b9597347306d1ec8eb4e1319b1e6986f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/var-dumper/zipball/34d8d4c4b9597347306d1ec8eb4e1319b1e6986f", + "reference": "34d8d4c4b9597347306d1ec8eb4e1319b1e6986f", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-mbstring": "~1.0" + }, + "conflict": { + "symfony/console": "<6.4" + }, + "require-dev": { + "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.3" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-08-13T11:49:31+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": "ueberdosis/tiptap-php", + "version": "2.0.0", + "source": { + "type": "git", + "url": "https://github.com/ueberdosis/tiptap-php.git", + "reference": "458194ad0f8b0cf616fecdf451a84f9a6c1f3056" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ueberdosis/tiptap-php/zipball/458194ad0f8b0cf616fecdf451a84f9a6c1f3056", + "reference": "458194ad0f8b0cf616fecdf451a84f9a6c1f3056", + "shasum": "" + }, + "require": { + "php": "^8.0", + "scrivo/highlight.php": "^9.18", + "spatie/shiki-php": "^2.0" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^3.5", + "pestphp/pest": "^1.21", + "phpunit/phpunit": "^9.5", + "vimeo/psalm": "^4.3" + }, + "type": "library", + "autoload": { + "psr-4": { + "Tiptap\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Hans Pagel", + "email": "humans@tiptap.dev", + "role": "Developer" + } + ], + "description": "A PHP package to work with Tiptap output", + "homepage": "https://github.com/ueberdosis/tiptap-php", + "keywords": [ + "prosemirror", + "tiptap", + "ueberdosis" + ], + "support": { + "issues": "https://github.com/ueberdosis/tiptap-php/issues", + "source": "https://github.com/ueberdosis/tiptap-php/tree/2.0.0" + }, + "funding": [ + { + "url": "https://tiptap.dev/pricing", + "type": "custom" + }, + { + "url": "https://github.com/ueberdosis", + "type": "github" + }, + { + "url": "https://opencollective.com/tiptap", + "type": "open_collective" + } + ], + "time": "2025-06-26T14:11:46+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": "willdurand/email-reply-parser", + "version": "2.10.0", + "source": { + "type": "git", + "url": "https://github.com/willdurand/EmailReplyParser.git", + "reference": "f25f8c6c3cb876112e3857eb3da15e38e44e4725" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/willdurand/EmailReplyParser/zipball/f25f8c6c3cb876112e3857eb3da15e38e44e4725", + "reference": "f25f8c6c3cb876112e3857eb3da15e38e44e4725", + "shasum": "" + }, + "require": { + "php": ">=7.4.0" + }, + "require-dev": { + "symfony/phpunit-bridge": "^5.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.8-dev" + } + }, + "autoload": { + "psr-4": { + "EmailReplyParser\\": "src/EmailReplyParser" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "William Durand", + "email": "will+git@drnd.me" + } + ], + "description": "Port of the cool GitHub's EmailReplyParser library in PHP", + "keywords": [ + "email", + "reply-parser" + ], + "support": { + "issues": "https://github.com/willdurand/EmailReplyParser/issues", + "source": "https://github.com/willdurand/EmailReplyParser/tree/2.10.0" + }, + "time": "2022-01-30T20:56:36+00:00" + }, + { + "name": "zbateson/mail-mime-parser", + "version": "3.0.4", + "source": { + "type": "git", + "url": "https://github.com/zbateson/mail-mime-parser.git", + "reference": "f0ccec9290a5b9cf014d7b7ea3401d2a4a626e9a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/zbateson/mail-mime-parser/zipball/f0ccec9290a5b9cf014d7b7ea3401d2a4a626e9a", + "reference": "f0ccec9290a5b9cf014d7b7ea3401d2a4a626e9a", + "shasum": "" + }, + "require": { + "guzzlehttp/psr7": "^2.5", + "php": ">=8.0", + "php-di/php-di": "^6.0|^7.0", + "psr/log": "^1|^2|^3", + "zbateson/mb-wrapper": "^2.0", + "zbateson/stream-decorators": "^2.1" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "*", + "monolog/monolog": "^2|^3", + "phpstan/phpstan": "*", + "phpunit/phpunit": "^9.6" + }, + "suggest": { + "ext-iconv": "For best support/performance", + "ext-mbstring": "For best support/performance" + }, + "type": "library", + "autoload": { + "psr-4": { + "ZBateson\\MailMimeParser\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-2-Clause" + ], + "authors": [ + { + "name": "Zaahid Bateson" + }, + { + "name": "Contributors", + "homepage": "https://github.com/zbateson/mail-mime-parser/graphs/contributors" + } + ], + "description": "MIME email message parser", + "homepage": "https://mail-mime-parser.org", + "keywords": [ + "MimeMailParser", + "email", + "mail", + "mailparse", + "mime", + "mimeparse", + "parser", + "php-imap" + ], + "support": { + "docs": "https://mail-mime-parser.org/#usage-guide", + "issues": "https://github.com/zbateson/mail-mime-parser/issues", + "source": "https://github.com/zbateson/mail-mime-parser" + }, + "funding": [ + { + "url": "https://github.com/zbateson", + "type": "github" + } + ], + "time": "2025-09-03T17:18:36+00:00" + }, + { + "name": "zbateson/mb-wrapper", + "version": "2.0.1", + "source": { + "type": "git", + "url": "https://github.com/zbateson/mb-wrapper.git", + "reference": "50a14c0c9537f978a61cde9fdc192a0267cc9cff" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/zbateson/mb-wrapper/zipball/50a14c0c9537f978a61cde9fdc192a0267cc9cff", + "reference": "50a14c0c9537f978a61cde9fdc192a0267cc9cff", + "shasum": "" + }, + "require": { + "php": ">=8.0", + "symfony/polyfill-iconv": "^1.9", + "symfony/polyfill-mbstring": "^1.9" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "*", + "phpstan/phpstan": "*", + "phpunit/phpunit": "^9.6|^10.0" + }, + "suggest": { + "ext-iconv": "For best support/performance", + "ext-mbstring": "For best support/performance" + }, + "type": "library", + "autoload": { + "psr-4": { + "ZBateson\\MbWrapper\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-2-Clause" + ], + "authors": [ + { + "name": "Zaahid Bateson" + } + ], + "description": "Wrapper for mbstring with fallback to iconv for encoding conversion and string manipulation", + "keywords": [ + "charset", + "encoding", + "http", + "iconv", + "mail", + "mb", + "mb_convert_encoding", + "mbstring", + "mime", + "multibyte", + "string" + ], + "support": { + "issues": "https://github.com/zbateson/mb-wrapper/issues", + "source": "https://github.com/zbateson/mb-wrapper/tree/2.0.1" + }, + "funding": [ + { + "url": "https://github.com/zbateson", + "type": "github" + } + ], + "time": "2024-12-20T22:05:33+00:00" + }, + { + "name": "zbateson/stream-decorators", + "version": "2.1.1", + "source": { + "type": "git", + "url": "https://github.com/zbateson/stream-decorators.git", + "reference": "32a2a62fb0f26313395c996ebd658d33c3f9c4e5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/zbateson/stream-decorators/zipball/32a2a62fb0f26313395c996ebd658d33c3f9c4e5", + "reference": "32a2a62fb0f26313395c996ebd658d33c3f9c4e5", + "shasum": "" + }, + "require": { + "guzzlehttp/psr7": "^2.5", + "php": ">=8.0", + "zbateson/mb-wrapper": "^2.0" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "*", + "phpstan/phpstan": "*", + "phpunit/phpunit": "^9.6|^10.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "ZBateson\\StreamDecorators\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-2-Clause" + ], + "authors": [ + { + "name": "Zaahid Bateson" + } + ], + "description": "PHP psr7 stream decorators for mime message part streams", + "keywords": [ + "base64", + "charset", + "decorators", + "mail", + "mime", + "psr7", + "quoted-printable", + "stream", + "uuencode" + ], + "support": { + "issues": "https://github.com/zbateson/stream-decorators/issues", + "source": "https://github.com/zbateson/stream-decorators/tree/2.1.1" + }, + "funding": [ + { + "url": "https://github.com/zbateson", + "type": "github" + } + ], + "time": "2024-04-29T21:42:39+00:00" + } + ], + "packages-dev": [ + { + "name": "barryvdh/laravel-debugbar", + "version": "v3.16.0", + "source": { + "type": "git", + "url": "https://github.com/barryvdh/laravel-debugbar.git", + "reference": "f265cf5e38577d42311f1a90d619bcd3740bea23" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/barryvdh/laravel-debugbar/zipball/f265cf5e38577d42311f1a90d619bcd3740bea23", + "reference": "f265cf5e38577d42311f1a90d619bcd3740bea23", + "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.2.0", + "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.16-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.16.0" + }, + "funding": [ + { + "url": "https://fruitcake.nl", + "type": "custom" + }, + { + "url": "https://github.com/barryvdh", + "type": "github" + } + ], + "time": "2025-07-14T11:56:43+00:00" + }, + { + "name": "dedoc/scramble", + "version": "v0.12.34", + "source": { + "type": "git", + "url": "https://github.com/dedoc/scramble.git", + "reference": "fdedbaac3271b466182d4029651aa90eac53cee5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/dedoc/scramble/zipball/fdedbaac3271b466182d4029651aa90eac53cee5", + "reference": "fdedbaac3271b466182d4029651aa90eac53cee5", + "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.34" + }, + "funding": [ + { + "url": "https://github.com/romalytvynenko", + "type": "github" + } + ], + "time": "2025-09-09T14:36:44+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.4", + "source": { + "type": "git", + "url": "https://github.com/filp/whoops.git", + "reference": "d2102955e48b9fd9ab24280a7ad12ed552752c4d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/filp/whoops/zipball/d2102955e48b9fd9ab24280a7ad12ed552752c4d", + "reference": "d2102955e48b9fd9ab24280a7ad12ed552752c4d", + "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.4" + }, + "funding": [ + { + "url": "https://github.com/denis-sokolov", + "type": "github" + } + ], + "time": "2025-08-08T12:00:00+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.7.1", + "source": { + "type": "git", + "url": "https://github.com/larastan/larastan.git", + "reference": "2e653fd19585a825e283b42f38378b21ae481cc7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/larastan/larastan/zipball/2e653fd19585a825e283b42f38378b21ae481cc7", + "reference": "2e653fd19585a825e283b42f38378b21ae481cc7", + "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.23" + }, + "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.7.1" + }, + "funding": [ + { + "url": "https://github.com/canvural", + "type": "github" + } + ], + "time": "2025-09-10T19:42:11+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.45.0", + "source": { + "type": "git", + "url": "https://github.com/laravel/sail.git", + "reference": "019a2933ff4a9199f098d4259713f9bc266a874e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/sail/zipball/019a2933ff4a9199f098d4259713f9bc266a874e", + "reference": "019a2933ff4a9199f098d4259713f9bc266a874e", + "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-08-25T19:28:31+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.4", + "source": { + "type": "git", + "url": "https://github.com/myclabs/DeepCopy.git", + "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/07d290f0c47959fd5eed98c95ee5602db07e0b6a", + "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a", + "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.4" + }, + "funding": [ + { + "url": "https://tidelift.com/funding/github/packagist/myclabs/deep-copy", + "type": "tidelift" + } + ], + "time": "2025-08-01T08:46:24+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.2.4", + "source": { + "type": "git", + "url": "https://github.com/php-debugbar/php-debugbar.git", + "reference": "3146d04671f51f69ffec2a4207ac3bdcf13a9f35" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-debugbar/php-debugbar/zipball/3146d04671f51f69ffec2a4207ac3bdcf13a9f35", + "reference": "3146d04671f51f69ffec2a4207ac3bdcf13a9f35", + "shasum": "" + }, + "require": { + "php": "^8", + "psr/log": "^1|^2|^3", + "symfony/var-dumper": "^4|^5|^6|^7" + }, + "replace": { + "maximebf/debugbar": "self.version" + }, + "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.1-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.2.4" + }, + "time": "2025-07-22T14:01:30+00:00" + }, + { + "name": "phpstan/phpdoc-parser", + "version": "2.3.0", + "source": { + "type": "git", + "url": "https://github.com/phpstan/phpdoc-parser.git", + "reference": "1e0cd5370df5dd2e556a36b9c62f62e555870495" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/1e0cd5370df5dd2e556a36b9c62f62e555870495", + "reference": "1e0cd5370df5dd2e556a36b9c62f62e555870495", + "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.3.0" + }, + "time": "2025-08-30T15:50:23+00:00" + }, + { + "name": "phpstan/phpstan", + "version": "2.1.25", + "source": { + "type": "git", + "url": "https://github.com/phpstan/phpstan.git", + "reference": "4087d28bd252895874e174d65e26b2c202ed893a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/4087d28bd252895874e174d65e26b2c202ed893a", + "reference": "4087d28bd252895874e174d65e26b2c202ed893a", + "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-09-12T14:26:42+00:00" + }, + { + "name": "phpunit/php-code-coverage", + "version": "11.0.11", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-code-coverage.git", + "reference": "4f7722aa9a7b76aa775e2d9d4e95d1ea16eeeef4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/4f7722aa9a7b76aa775e2d9d4e95d1ea16eeeef4", + "reference": "4f7722aa9a7b76aa775e2d9d4e95d1ea16eeeef4", + "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/11.0.11" + }, + "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-08-27T14:37:49+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.39", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/phpunit.git", + "reference": "ad5597f79d8489d2870073ac0bc0dd0ad1fa9931" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/ad5597f79d8489d2870073ac0bc0dd0ad1fa9931", + "reference": "ad5597f79d8489d2870073ac0bc0dd0ad1fa9931", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-json": "*", + "ext-libxml": "*", + "ext-mbstring": "*", + "ext-xml": "*", + "ext-xmlwriter": "*", + "myclabs/deep-copy": "^1.13.4", + "phar-io/manifest": "^2.0.4", + "phar-io/version": "^3.2.1", + "php": ">=8.2", + "phpunit/php-code-coverage": "^11.0.11", + "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.2", + "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.3", + "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.39" + }, + "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-09-14T06:20:41+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.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/comparator.git", + "reference": "85c77556683e6eee4323e4c5468641ca0237e2e8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/85c77556683e6eee4323e4c5468641ca0237e2e8", + "reference": "85c77556683e6eee4323e4c5468641ca0237e2e8", + "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.2" + }, + "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/comparator", + "type": "tidelift" + } + ], + "time": "2025-08-10T08:07:46+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.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/recursion-context.git", + "reference": "f6458abbf32a6c8174f8f26261475dc133b3d9dc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/f6458abbf32a6c8174f8f26261475dc133b3d9dc", + "reference": "f6458abbf32a6c8174f8f26261475dc133b3d9dc", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.3" + }, + "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.3" + }, + "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/recursion-context", + "type": "tidelift" + } + ], + "time": "2025-08-13T04:42:22+00:00" + }, + { + "name": "sebastian/type", + "version": "5.1.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/type.git", + "reference": "f77d2d4e78738c98d9a68d2596fe5e8fa380f449" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/f77d2d4e78738c98d9a68d2596fe5e8fa380f449", + "reference": "f77d2d4e78738c98d9a68d2596fe5e8fa380f449", + "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.3" + }, + "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/type", + "type": "tidelift" + } + ], + "time": "2025-08-09T06:55:48+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.8.1", + "source": { + "type": "git", + "url": "https://github.com/spatie/backtrace.git", + "reference": "8c0f16a59ae35ec8c62d85c3c17585158f430110" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/spatie/backtrace/zipball/8c0f16a59ae35ec8c62d85c3c17585158f430110", + "reference": "8c0f16a59ae35ec8c62d85c3c17585158f430110", + "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": { + "issues": "https://github.com/spatie/backtrace/issues", + "source": "https://github.com/spatie/backtrace/tree/1.8.1" + }, + "funding": [ + { + "url": "https://github.com/sponsors/spatie", + "type": "github" + }, + { + "url": "https://spatie.be/open-source/support-us", + "type": "other" + } + ], + "time": "2025-08-26T08:22:30+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.3", + "source": { + "type": "git", + "url": "https://github.com/symfony/yaml.git", + "reference": "d4f4a66866fe2451f61296924767280ab5732d9d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/yaml/zipball/d4f4a66866fe2451f61296924767280ab5732d9d", + "reference": "d4f4a66866fe2451f61296924767280ab5732d9d", + "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.3" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-08-27T11:34:33+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 0000000..41fff2e --- /dev/null +++ b/config/app.php @@ -0,0 +1,412 @@ + env('APP_NAME', 'Paymenter'), + + 'version' => '1.3.4', + + '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/audit.php b/config/audit.php new file mode 100644 index 0000000..32a62a8 --- /dev/null +++ b/config/audit.php @@ -0,0 +1,198 @@ + env('AUDITING_ENABLED', true), + + /* + |-------------------------------------------------------------------------- + | Audit Implementation + |-------------------------------------------------------------------------- + | + | Define which Audit model implementation should be used. + | + */ + + 'implementation' => OwenIt\Auditing\Models\Audit::class, + + /* + |-------------------------------------------------------------------------- + | User Morph prefix & Guards + |-------------------------------------------------------------------------- + | + | Define the morph prefix and authentication guards for the User resolver. + | + */ + + 'user' => [ + 'morph_prefix' => 'user', + 'guards' => [ + 'web', + 'api', + ], + 'resolver' => OwenIt\Auditing\Resolvers\UserResolver::class, + ], + + /* + |-------------------------------------------------------------------------- + | Audit Resolvers + |-------------------------------------------------------------------------- + | + | Define the IP Address, User Agent and URL resolver implementations. + | + */ + 'resolvers' => [ + 'ip_address' => OwenIt\Auditing\Resolvers\IpAddressResolver::class, + 'user_agent' => OwenIt\Auditing\Resolvers\UserAgentResolver::class, + 'url' => App\Resolvers\UrlResolver::class, + ], + + /* + |-------------------------------------------------------------------------- + | Audit Events + |-------------------------------------------------------------------------- + | + | The Eloquent events that trigger an Audit. + | + */ + + 'events' => [ + 'created', + 'updated', + 'deleted', + 'restored', + ], + + /* + |-------------------------------------------------------------------------- + | Strict Mode + |-------------------------------------------------------------------------- + | + | Enable the strict mode when auditing? + | + */ + + 'strict' => false, + + /* + |-------------------------------------------------------------------------- + | Global exclude + |-------------------------------------------------------------------------- + | + | Have something you always want to exclude by default? - add it here. + | Note that this is overwritten (not merged) with local exclude + | + */ + + 'exclude' => [], + + /* + |-------------------------------------------------------------------------- + | Empty Values + |-------------------------------------------------------------------------- + | + | Should Audit records be stored when the recorded old_values & new_values + | are both empty? + | + | Some events may be empty on purpose. Use allowed_empty_values to exclude + | those from the empty values check. For example when auditing + | model retrieved events which will never have new and old values. + | + | + */ + + 'empty_values' => false, + 'allowed_empty_values' => [ + 'retrieved', + ], + + /* + |-------------------------------------------------------------------------- + | Allowed Array Values + |-------------------------------------------------------------------------- + | + | Should the array values be audited? + | + | By default, array values are not allowed. This is to prevent performance + | issues when storing large amounts of data. You can override this by + | setting allow_array_values to true. + */ + 'allowed_array_values' => false, + + /* + |-------------------------------------------------------------------------- + | Audit Timestamps + |-------------------------------------------------------------------------- + | + | Should the created_at, updated_at and deleted_at timestamps be audited? + | + */ + + 'timestamps' => false, + + /* + |-------------------------------------------------------------------------- + | Audit Threshold + |-------------------------------------------------------------------------- + | + | Specify a threshold for the amount of Audit records a model can have. + | Zero means no limit. + | + */ + + 'threshold' => 0, + + /* + |-------------------------------------------------------------------------- + | Audit Driver + |-------------------------------------------------------------------------- + | + | The default audit driver used to keep track of changes. + | + */ + + 'driver' => 'database', + + /* + |-------------------------------------------------------------------------- + | Audit Driver Configurations + |-------------------------------------------------------------------------- + | + | Available audit drivers and respective configurations. + | + */ + + 'drivers' => [ + 'database' => [ + 'table' => 'audits', + 'connection' => null, + ], + ], + + /* + |-------------------------------------------------------------------------- + | Audit Queue Configurations + |-------------------------------------------------------------------------- + | + | Available audit queue configurations. + | + */ + + 'queue' => [ + 'enable' => false, + 'connection' => 'sync', + 'queue' => 'default', + 'delay' => 0, + ], + + /* + |-------------------------------------------------------------------------- + | Audit Console + |-------------------------------------------------------------------------- + | + | Whether console events should be audited (eg. php artisan db:seed). + | + */ + + 'console' => false, +]; diff --git a/config/auth.php b/config/auth.php new file mode 100644 index 0000000..c3de502 --- /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 0000000..51cb1be --- /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/database.php b/config/database.php new file mode 100644 index 0000000..1791c74 --- /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' => env('DB_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 0000000..40b7e78 --- /dev/null +++ b/config/filesystems.php @@ -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/livewire.php b/config/livewire.php new file mode 100644 index 0000000..12c79d2 --- /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 0000000..d3f62c4 --- /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 0000000..bf685c3 --- /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/passport.php b/config/passport.php new file mode 100644 index 0000000..972e4e1 --- /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 0000000..539aae4 --- /dev/null +++ b/config/permissions.php @@ -0,0 +1,239 @@ + [ + '*' => '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', + ], + 'audits' => [ + 'viewAny' => 'View Audits', + ], + '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', + ], + 'invoice_items' => [ + 'create' => 'Create Invoice Items', + 'update' => 'Update Invoice Items', + 'view' => 'View Invoice Items', + 'delete' => 'Delete Invoice Items', + ], + '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/queue.php b/config/queue.php new file mode 100644 index 0000000..5bd4538 --- /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', 90), + '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 0000000..1979103 --- /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 0000000..6bb68f6 --- /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 0000000..b6e6efa --- /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 0000000..6d9ceca --- /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 0000000..9b19b93 --- /dev/null +++ b/database/.gitignore @@ -0,0 +1 @@ +*.sqlite* diff --git a/database/factories/CategoryFactory.php b/database/factories/CategoryFactory.php new file mode 100644 index 0000000..1bc2fa3 --- /dev/null +++ b/database/factories/CategoryFactory.php @@ -0,0 +1,26 @@ + + */ +class CategoryFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'name' => $this->faker->word(), + 'slug' => Str::slug($this->faker->word()), + 'description' => $this->faker->sentence(), + ]; + } +} diff --git a/database/factories/PlanFactory.php b/database/factories/PlanFactory.php new file mode 100644 index 0000000..e07b512 --- /dev/null +++ b/database/factories/PlanFactory.php @@ -0,0 +1,26 @@ + + */ +class PlanFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'name' => $this->faker->word(), + 'type' => $this->faker->randomElement(['free', 'recurring', 'one-time']), + 'billing_period' => $this->faker->randomDigitNotNull(), // Billing period in months + 'billing_unit' => $this->faker->randomElement(['month', 'year']), + ]; + } +} diff --git a/database/factories/PriceFactory.php b/database/factories/PriceFactory.php new file mode 100644 index 0000000..ee82a73 --- /dev/null +++ b/database/factories/PriceFactory.php @@ -0,0 +1,24 @@ + + */ +class PriceFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'price' => $this->faker->randomFloat(2, 1, 1000), // Random price between 1 and 1000 + 'currency_code' => 'USD', + ]; + } +} diff --git a/database/factories/ProductFactory.php b/database/factories/ProductFactory.php new file mode 100644 index 0000000..4f55494 --- /dev/null +++ b/database/factories/ProductFactory.php @@ -0,0 +1,27 @@ + + */ +class ProductFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'name' => $this->faker->word(), + 'description' => $this->faker->sentence(), + 'category_id' => \App\Models\Category::factory(), + 'slug' => Str::slug($this->faker->word()), + ]; + } +} diff --git a/database/factories/ServiceFactory.php b/database/factories/ServiceFactory.php new file mode 100644 index 0000000..38d84aa --- /dev/null +++ b/database/factories/ServiceFactory.php @@ -0,0 +1,30 @@ + + */ +class ServiceFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'price' => $this->faker->randomFloat(2, 1), + 'currency_code' => 'USD', + 'status' => $this->faker->randomElement([ + \App\Models\Service::STATUS_PENDING, + \App\Models\Service::STATUS_ACTIVE, + \App\Models\Service::STATUS_CANCELLED, + \App\Models\Service::STATUS_SUSPENDED, + ]), + ]; + } +} diff --git a/database/factories/TicketFactory.php b/database/factories/TicketFactory.php new file mode 100644 index 0000000..fbdc87c --- /dev/null +++ b/database/factories/TicketFactory.php @@ -0,0 +1,26 @@ + + */ +class TicketFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'subject' => $this->faker->sentence(1), + 'status' => $this->faker->randomElement(['active', 'replied', 'closed']), + 'priority' => $this->faker->randomElement(['low', 'medium', 'high']), + 'department' => $this->faker->word(), + ]; + } +} diff --git a/database/factories/TicketMessageFactory.php b/database/factories/TicketMessageFactory.php new file mode 100644 index 0000000..828c9bb --- /dev/null +++ b/database/factories/TicketMessageFactory.php @@ -0,0 +1,23 @@ + + */ +class TicketMessageFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'message' => $this->faker->paragraph(), + ]; + } +} diff --git a/database/factories/UserFactory.php b/database/factories/UserFactory.php new file mode 100644 index 0000000..19f04f3 --- /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 0000000..425e705 --- /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 0000000..3519241 --- /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 0000000..d017d6c --- /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 0000000..50ed34d --- /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 0000000..3efb8d5 --- /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 0000000..eb942e0 --- /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 0000000..e7a1b16 --- /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 0000000..e2d25e6 --- /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 0000000..79f7dbb --- /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 0000000..be6c7cc --- /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 0000000..35fed83 --- /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 0000000..d5eeebf --- /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 0000000..a67b698 --- /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 0000000..4c99e4e --- /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 0000000..ee7542a --- /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 0000000..fd1dc72 --- /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 0000000..e7e2669 --- /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 0000000..71f83e8 --- /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 0000000..931f40e --- /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 0000000..beff5c1 --- /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 0000000..f73e74f --- /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 0000000..5aff084 --- /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 0000000..4ca229d --- /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 0000000..c406c44 --- /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 0000000..c658189 --- /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 0000000..db0537b --- /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 0000000..cc0887e --- /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 0000000..db12ac3 --- /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 0000000..247a167 --- /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 0000000..eef9c33 --- /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 0000000..b007904 --- /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 0000000..8e437ea --- /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 0000000..15398c9 --- /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 0000000..3c60adc --- /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 0000000..e0b770a --- /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 0000000..7638a64 --- /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 0000000..aa1a21e --- /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 0000000..6547d5f --- /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 0000000..b80ceb6 --- /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 0000000..8ee1bda --- /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 0000000..7042aff --- /dev/null +++ b/database/migrations/2025_04_25_064729_add_number_to_invoices.php @@ -0,0 +1,45 @@ +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::withoutEvents(function () { + 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 0000000..f23bcd1 --- /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 0000000..8ccd114 --- /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 0000000..7d022c3 --- /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 0000000..d128c86 --- /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/migrations/2025_07_31_165713_create_ticket_message_attachments_table.php b/database/migrations/2025_07_31_165713_create_ticket_message_attachments_table.php new file mode 100644 index 0000000..53a1b09 --- /dev/null +++ b/database/migrations/2025_07_31_165713_create_ticket_message_attachments_table.php @@ -0,0 +1,34 @@ +id(); + $table->uuid('uuid'); + $table->string('filename'); + $table->string('path'); + $table->unsignedBigInteger('filesize'); + $table->string('mime_type'); + $table->foreignIdFor(TicketMessage::class)->constrained()->onDelete('cascade'); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('ticket_message_attachments'); + } +}; diff --git a/database/migrations/2025_08_02_150516_create_ticket_mail_logs_table.php b/database/migrations/2025_08_02_150516_create_ticket_mail_logs_table.php new file mode 100644 index 0000000..4959d77 --- /dev/null +++ b/database/migrations/2025_08_02_150516_create_ticket_mail_logs_table.php @@ -0,0 +1,33 @@ +id(); + $table->string('message_id'); + $table->string('subject'); + $table->string('from'); + $table->string('to'); + $table->text('body'); + $table->string('status'); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('ticket_mail_logs'); + } +}; diff --git a/database/migrations/2025_08_04_181811_add_ticket_mail_log_id_to_ticket_messages.php b/database/migrations/2025_08_04_181811_add_ticket_mail_log_id_to_ticket_messages.php new file mode 100644 index 0000000..6d07282 --- /dev/null +++ b/database/migrations/2025_08_04_181811_add_ticket_mail_log_id_to_ticket_messages.php @@ -0,0 +1,28 @@ +foreignIdFor(\App\Models\TicketMailLog::class)->nullable()->after('message')->constrained()->cascadeOnDelete(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('ticket_messages', function (Blueprint $table) { + $table->dropForeign(['ticket_mail_log_id']); + }); + } +}; diff --git a/database/migrations/2025_08_15_100111_change_gateway_id_on_invoice_transactions.php b/database/migrations/2025_08_15_100111_change_gateway_id_on_invoice_transactions.php new file mode 100644 index 0000000..68d15ac --- /dev/null +++ b/database/migrations/2025_08_15_100111_change_gateway_id_on_invoice_transactions.php @@ -0,0 +1,31 @@ +dropForeign(['gateway_id']); + $table->foreign('gateway_id')->references('id')->on('extensions')->nullOnDelete(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('invoice_transactions', function (Blueprint $table) { + $table->dropForeign(['gateway_id']); + $table->foreign('gateway_id')->references('id')->on('extensions')->cascadeOnDelete(); + }); + } +}; diff --git a/database/migrations/2025_08_15_132711_create_audits_table.php b/database/migrations/2025_08_15_132711_create_audits_table.php new file mode 100644 index 0000000..5838171 --- /dev/null +++ b/database/migrations/2025_08_15_132711_create_audits_table.php @@ -0,0 +1,40 @@ +bigIncrements('id'); + $table->string('user_type')->nullable(); + $table->unsignedBigInteger('user_id')->nullable(); + $table->string('event'); + $table->morphs('auditable'); + $table->text('old_values')->nullable(); + $table->text('new_values')->nullable(); + $table->text('url')->nullable(); + $table->ipAddress('ip_address')->nullable(); + $table->string('user_agent', 1023)->nullable(); + $table->string('tags')->nullable(); + $table->timestamps(); + + $table->index(['user_id', 'user_type']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('audits'); + } +}; diff --git a/database/migrations/2025_09_03_190940_add_name_to_currencies.php b/database/migrations/2025_09_03_190940_add_name_to_currencies.php new file mode 100644 index 0000000..efe729a --- /dev/null +++ b/database/migrations/2025_09_03_190940_add_name_to_currencies.php @@ -0,0 +1,39 @@ +string('name')->after('code')->nullable(); + }); + + // Set name for existing currencies + Currency::each(function ($currency) { + $currency->name = $currency->code; + $currency->save(); + }); + + Schema::table('currencies', function (Blueprint $table) { + $table->string('name')->nullable(false)->change(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('currencies', function (Blueprint $table) { + $table->dropColumn('name'); + }); + } +}; diff --git a/database/seeders/CustomPropertySeeder.php b/database/seeders/CustomPropertySeeder.php new file mode 100644 index 0000000..e7b619a --- /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 0000000..89f33ba --- /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', + 'name' => 'US Dollar', + 'prefix' => '$', + 'suffix' => '', + 'format' => '1.000,00', + ]); + } + + \App\Providers\SettingsProvider::flushCache(); + + $this->call([ + EmailTemplateSeeder::class, + ]); + } +} diff --git a/database/seeders/EmailTemplateSeeder.php b/database/seeders/EmailTemplateSeeder.php new file mode 100644 index 0000000..689c077 --- /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' => '[Ticket #{{ $ticketMessage->ticket_id }}] New 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 0000000..deceeb3 --- /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 0000000..8190993 --- /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 0000000..f7486c0 --- /dev/null +++ b/database/seeders/data/mail_header @@ -0,0 +1,51 @@ + + + +{{ config('app.name') }} + + + + + + + + + + +