v1.4.0
This commit is contained in:
29
Dockerfile
29
Dockerfile
@@ -3,30 +3,37 @@
|
||||
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 .
|
||||
&& apk del autoconf make g++ gcc libc-dev
|
||||
RUN curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer
|
||||
|
||||
RUN rm /usr/local/etc/php-fpm.conf \
|
||||
COPY composer.json composer.lock ./
|
||||
RUN composer install --no-dev --no-autoloader --no-scripts
|
||||
|
||||
COPY . ./
|
||||
RUN composer install --no-dev --optimize-autoloader
|
||||
|
||||
RUN cp .env.example .env \
|
||||
&& chmod 777 -R bootstrap storage/* \
|
||||
&& rm -rf .env bootstrap/cache/*.php \
|
||||
&& chown -R nginx:nginx . \
|
||||
&& 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 package.json package-lock.json ./
|
||||
RUN npm install
|
||||
|
||||
COPY . ./
|
||||
COPY --from=final /app/vendor /app/vendor
|
||||
RUN npm install \
|
||||
&& npm run build
|
||||
RUN npm run build
|
||||
|
||||
# Switch back to the final stage
|
||||
FROM final AS production
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
<a href="https://paymenter.org">Website</a> ·
|
||||
<a href="https://paymenter.org/docs/installation/install">Documentation</a> ·
|
||||
<a href="https://demo.paymenter.org">Live Demo</a> ·
|
||||
<a href="https://builtbybit.com/resources/categories/paymenter.76/">Extensions</a>
|
||||
<a href="https://paymenter.org/marketplace">Extensions</a>
|
||||
</h4>
|
||||
|
||||
<div align="center">
|
||||
@@ -32,7 +32,7 @@
|
||||
[](https://github.com/Paymenter/paymenter/releases)
|
||||
<br>
|
||||
<br>
|
||||
[](https://discord.gg/xB4UUT3XQg)
|
||||
[](https://discord.gg/paymenter-882318291014651924)
|
||||
|
||||
</div>
|
||||
|
||||
@@ -80,6 +80,11 @@ Paymenter is available under the MIT license, offering you the freedom to adapt
|
||||
|
||||
Thanks to all sponsors for helping fund Paymenter's development. [Interested in becoming a sponsor?](https://github.com/sponsors/Paymenter)
|
||||
|
||||
<a href="https://nodedog.consulting/?rel=paymenter">
|
||||
<img src="https://github.com/user-attachments/assets/d31a9ac5-aca4-476b-a678-55cc694df1aa" width="300">
|
||||
</a>
|
||||
|
||||
|
||||
## License
|
||||
|
||||
Licensed under the [MIT License](https://github.com/Paymenter/Paymenter/blob/master/LICENSE).
|
||||
|
||||
58
app/Admin/Pages/CronStats.php
Normal file
58
app/Admin/Pages/CronStats.php
Normal file
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
namespace App\Admin\Pages;
|
||||
|
||||
use App\Admin\Widgets\CronStat\CronOverview;
|
||||
use App\Admin\Widgets\CronStat\CronStat;
|
||||
use App\Admin\Widgets\CronStat\CronTable;
|
||||
use Filament\Forms\Components\DatePicker;
|
||||
use Filament\Pages\Dashboard;
|
||||
use Filament\Pages\Dashboard\Actions\FilterAction;
|
||||
use Filament\Pages\Dashboard\Concerns\HasFiltersAction;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
||||
class CronStats extends Dashboard
|
||||
{
|
||||
use HasFiltersAction;
|
||||
|
||||
protected static string|\UnitEnum|null $navigationGroup = 'System';
|
||||
|
||||
protected static ?string $title = 'Cron Statistics';
|
||||
|
||||
protected static string|\BackedEnum|null $navigationIcon = 'ri-time-line';
|
||||
|
||||
protected static string|\BackedEnum|null $activeNavigationIcon = 'ri-time-fill';
|
||||
|
||||
protected static ?int $navigationSort = 4;
|
||||
|
||||
protected static string $routePath = 'cron-stats';
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
FilterAction::make()
|
||||
->slideOver(false)
|
||||
->schema([
|
||||
DatePicker::make('date')
|
||||
->default(now())
|
||||
->label('Select Date')
|
||||
->required(),
|
||||
]),
|
||||
];
|
||||
}
|
||||
|
||||
public function getWidgets(): array
|
||||
{
|
||||
// but filter out
|
||||
return [
|
||||
CronOverview::class,
|
||||
CronTable::class,
|
||||
CronStat::class,
|
||||
];
|
||||
}
|
||||
|
||||
public static function canAccess(): bool
|
||||
{
|
||||
return Auth::user()->hasPermission('admin.cron_stats.view');
|
||||
}
|
||||
}
|
||||
@@ -12,12 +12,14 @@ use Filament\Actions\Contracts\HasActions;
|
||||
use Filament\Forms\Components\FileUpload;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Pages\Page;
|
||||
use Filament\Tables\Columns\ImageColumn;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Concerns\InteractsWithTable;
|
||||
use Filament\Tables\Contracts\HasTable;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Http\Client\ConnectionException;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Livewire\Attributes\Url;
|
||||
@@ -126,6 +128,9 @@ class Extension extends Page implements HasActions, HasTable
|
||||
->records(fn () => collect(ExtensionHelper::getInstallableExtensions()))
|
||||
->description('List of available extensions (not gateway or server extensions) that can be installed.')
|
||||
->columns([
|
||||
ImageColumn::make('meta.icon')
|
||||
->label('Icon')
|
||||
->state(fn ($record) => $record['meta']?->icon ? $record['meta']->icon : 'ri-puzzle-fill'),
|
||||
TextColumn::make('meta.name')
|
||||
->label('Extension Name')
|
||||
->searchable()
|
||||
@@ -208,4 +213,9 @@ class Extension extends Page implements HasActions, HasTable
|
||||
}),
|
||||
]);
|
||||
}
|
||||
|
||||
public static function canAccess(): bool
|
||||
{
|
||||
return Auth::user()->hasPermission('admin.extensions.viewAny') && Auth::user()->hasPermission('admin.extensions.install');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace App\Admin\Resources;
|
||||
use App\Admin\Resources\CategoryResource\Pages\CreateCategory;
|
||||
use App\Admin\Resources\CategoryResource\Pages\EditCategory;
|
||||
use App\Admin\Resources\CategoryResource\Pages\ListCategories;
|
||||
use App\Admin\Resources\CategoryResource\RelationManagers\ProductsRelationManager;
|
||||
use App\Models\Category;
|
||||
use Filament\Actions\BulkActionGroup;
|
||||
use Filament\Actions\DeleteBulkAction;
|
||||
@@ -116,7 +117,7 @@ class CategoryResource extends Resource
|
||||
public static function getRelations(): array
|
||||
{
|
||||
return [
|
||||
//
|
||||
ProductsRelationManager::class,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace App\Admin\Resources\CategoryResource\RelationManagers;
|
||||
|
||||
use App\Admin\Resources\ProductResource;
|
||||
use Filament\Actions\CreateAction;
|
||||
use Filament\Resources\RelationManagers\RelationManager;
|
||||
use Filament\Tables\Table;
|
||||
|
||||
class ProductsRelationManager extends RelationManager
|
||||
{
|
||||
protected static string $relationship = 'products';
|
||||
|
||||
protected static ?string $relatedResource = ProductResource::class;
|
||||
|
||||
public function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->reorderable('sort')
|
||||
->headerActions([
|
||||
CreateAction::make(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -52,7 +52,6 @@ class CouponResource extends Resource
|
||||
$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'),
|
||||
|
||||
@@ -64,18 +63,26 @@ class CouponResource extends Resource
|
||||
->options([
|
||||
'percentage' => 'Percentage',
|
||||
'fixed' => 'Fixed amount',
|
||||
'free_setup' => 'Free setup',
|
||||
])
|
||||
->placeholder('Select the type of the coupon'),
|
||||
Select::make('applies_to')
|
||||
->label('Applies To')
|
||||
->required()
|
||||
->default('all')
|
||||
->options([
|
||||
'all' => 'Price and Setup Fee',
|
||||
'price' => 'Price only',
|
||||
'setup_fee' => 'Setup Fee only',
|
||||
]),
|
||||
|
||||
TextInput::make('recurring')
|
||||
->label('Recurring')
|
||||
->numeric()
|
||||
->nullable()
|
||||
->minValue(0)
|
||||
->hidden(fn (Get $get) => $get('type') === 'free_setup')
|
||||
->hidden(fn (Get $get) => $get('applies_to') === '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.'),
|
||||
->helperText('Enter 0 to apply it to all billing cycles, 1 (or leave empty) to apply it only to the first billing cycle, etc.'),
|
||||
|
||||
TextInput::make('max_uses')
|
||||
->label('Max Uses')
|
||||
|
||||
@@ -4,7 +4,6 @@ namespace App\Admin\Resources;
|
||||
|
||||
use App\Admin\Resources\ErrorLogResource\Pages\ListErrorLogs;
|
||||
use App\Models\DebugLog;
|
||||
use Filament\Actions\BulkActionGroup;
|
||||
use Filament\Actions\DeleteBulkAction;
|
||||
use Filament\Actions\ViewAction;
|
||||
use Filament\Infolists\Components\TextEntry;
|
||||
@@ -13,6 +12,7 @@ use Filament\Schemas\Schema;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
||||
class ErrorLogResource extends Resource
|
||||
{
|
||||
@@ -58,9 +58,7 @@ class ErrorLogResource extends Resource
|
||||
ViewAction::make(),
|
||||
])
|
||||
->toolbarActions([
|
||||
BulkActionGroup::make([
|
||||
DeleteBulkAction::make(),
|
||||
]),
|
||||
DeleteBulkAction::make(),
|
||||
])
|
||||
->defaultSort('created_at', 'desc');
|
||||
}
|
||||
@@ -102,4 +100,9 @@ class ErrorLogResource extends Resource
|
||||
'index' => ListErrorLogs::route('/'),
|
||||
];
|
||||
}
|
||||
|
||||
public static function canAccess(): bool
|
||||
{
|
||||
return Auth::user()->hasPermission('admin.debug_logs.view');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,6 +81,7 @@ class FailedJobResource extends Resource
|
||||
try {
|
||||
Artisan::call("queue:retry {$record->uuid}");
|
||||
} catch (Exception $e) {
|
||||
report($e);
|
||||
Notification::make()
|
||||
->title($e->getMessage())
|
||||
->warning()
|
||||
@@ -108,10 +109,7 @@ class FailedJobResource extends Resource
|
||||
})
|
||||
->deselectRecordsAfterCompletion(),
|
||||
])
|
||||
->defaultSort('failed_at', 'desc')
|
||||
->filters([
|
||||
|
||||
]);
|
||||
->defaultSort('failed_at', 'desc');
|
||||
}
|
||||
|
||||
public static function canCreate(): bool
|
||||
|
||||
@@ -14,6 +14,7 @@ use Filament\Schemas\Schema;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
||||
class HttpLogResource extends Resource
|
||||
{
|
||||
@@ -126,4 +127,9 @@ class HttpLogResource extends Resource
|
||||
'index' => ListHttpLogs::route('/'),
|
||||
];
|
||||
}
|
||||
|
||||
public static function canAccess(): bool
|
||||
{
|
||||
return Auth::user()->hasPermission('admin.debug_logs.view');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ class EditInvoice extends EditRecord
|
||||
->action(function (Invoice $invoice) {
|
||||
return response()->streamDownload(function () use ($invoice) {
|
||||
echo PDF::generateInvoice($invoice)->stream();
|
||||
}, 'invoice-' . $invoice->number . '.pdf');
|
||||
}, 'invoice-' . ($invoice->number ?? $invoice->id) . '.pdf');
|
||||
}),
|
||||
AuditAction::make()
|
||||
->auditChildren([
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Admin\Resources\InvoiceTransactions\Tables;
|
||||
|
||||
use App\Admin\Resources\InvoiceResource;
|
||||
use App\Enums\InvoiceTransactionStatus;
|
||||
use App\Models\InvoiceTransaction;
|
||||
use Filament\Actions\BulkActionGroup;
|
||||
use Filament\Actions\DeleteAction;
|
||||
@@ -30,6 +31,17 @@ class InvoiceTransactionsTable
|
||||
->sortable(),
|
||||
TextColumn::make('transaction_id')
|
||||
->searchable(),
|
||||
TextColumn::make('status')
|
||||
->sortable()
|
||||
->badge()
|
||||
->color(fn (InvoiceTransaction $record) => match ($record->status) {
|
||||
InvoiceTransactionStatus::Succeeded => 'success',
|
||||
InvoiceTransactionStatus::Processing => 'warning',
|
||||
InvoiceTransactionStatus::Failed => 'danger',
|
||||
default => null,
|
||||
})
|
||||
->formatStateUsing(fn (InvoiceTransactionStatus $state): string => ucfirst($state->value))
|
||||
->label('Status'),
|
||||
TextColumn::make('created_at')
|
||||
->dateTime()
|
||||
->sortable()
|
||||
|
||||
148
app/Admin/Resources/NotificationTemplateResource.php
Normal file
148
app/Admin/Resources/NotificationTemplateResource.php
Normal file
@@ -0,0 +1,148 @@
|
||||
<?php
|
||||
|
||||
namespace App\Admin\Resources;
|
||||
|
||||
use App\Admin\Resources\NotificationTemplateResource\Pages\CreateNotificationTemplate;
|
||||
use App\Admin\Resources\NotificationTemplateResource\Pages\EditNotificationTemplate;
|
||||
use App\Admin\Resources\NotificationTemplateResource\Pages\ListNotificationTemplates;
|
||||
use App\Enums\NotificationEnabledStatus;
|
||||
use App\Models\NotificationTemplate;
|
||||
use Filament\Actions\EditAction;
|
||||
use Filament\Forms\Components\MarkdownEditor;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Forms\Components\TagsInput;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Forms\Components\Toggle;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Schemas\Components\Section;
|
||||
use Filament\Schemas\Components\Utilities\Get;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Tables\Columns\IconColumn;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Table;
|
||||
|
||||
class NotificationTemplateResource extends Resource
|
||||
{
|
||||
protected static ?string $model = NotificationTemplate::class;
|
||||
|
||||
protected static string|\BackedEnum|null $navigationIcon = 'ri-mail-settings-line';
|
||||
|
||||
protected static string|\BackedEnum|null $activeNavigationIcon = 'ri-mail-settings-fill';
|
||||
|
||||
protected static string|\UnitEnum|null $navigationGroup = 'Other';
|
||||
|
||||
public static function form(Schema $schema): Schema
|
||||
{
|
||||
return $schema
|
||||
->components([
|
||||
TextInput::make('key')
|
||||
->required()
|
||||
->disabledOn('edit')
|
||||
->maxLength(255),
|
||||
Toggle::make('enabled')
|
||||
->required(),
|
||||
TextInput::make('edit_preference_message')
|
||||
->label('Edit Preference Message')
|
||||
->hint('This message will be shown to users when they edit their notification preferences for this template.')
|
||||
->columnSpanFull(),
|
||||
Section::make('Email Template')
|
||||
->description('Define the subject and body of the email template. You can use either Markdown or HTML for the body content.')
|
||||
->columns(2)
|
||||
->columnSpanFull()
|
||||
->collapsible()
|
||||
->schema([
|
||||
TextInput::make('subject')
|
||||
->required()
|
||||
->maxLength(255),
|
||||
Select::make('mail_enabled')
|
||||
->label('Mail Enabled')
|
||||
->options([
|
||||
NotificationEnabledStatus::ChoiceOn->value => 'User Choice, Default On',
|
||||
NotificationEnabledStatus::ChoiceOff->value => 'User Choice, Default Off',
|
||||
NotificationEnabledStatus::Force->value => 'Force On',
|
||||
NotificationEnabledStatus::Never->value => 'Force Off',
|
||||
])
|
||||
->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']),
|
||||
TagsInput::make('bcc')
|
||||
->nestedRecursiveRules(['required', 'email'])
|
||||
->placeholder('mail@example.com'),
|
||||
]),
|
||||
Section::make('In-App Notification (push)')
|
||||
->description('Define the title and body of the in-app notification that users will receive.')
|
||||
->columns(2)
|
||||
->columnSpanFull()
|
||||
->collapsible()
|
||||
->schema([
|
||||
TextInput::make('in_app_title')
|
||||
->label('In-App Title')
|
||||
->required()
|
||||
->disabled(fn (Get $get) => $get('in_app_enabled') === NotificationEnabledStatus::Never->value)
|
||||
->maxLength(255),
|
||||
Select::make('in_app_enabled')
|
||||
->label('In-App Enabled')
|
||||
->options([
|
||||
NotificationEnabledStatus::ChoiceOn->value => 'User Choice, Default On',
|
||||
NotificationEnabledStatus::ChoiceOff->value => 'User Choice, Default Off',
|
||||
NotificationEnabledStatus::Force->value => 'Force On',
|
||||
NotificationEnabledStatus::Never->value => 'Force Off',
|
||||
])
|
||||
->live()
|
||||
->required(),
|
||||
TextInput::make('in_app_body')
|
||||
->label('In-App Body')
|
||||
->required()
|
||||
->disabled(fn (Get $get) => $get('in_app_enabled') === NotificationEnabledStatus::Never->value)
|
||||
->columnSpanFull(),
|
||||
TextInput::make('in_app_url')
|
||||
->label('In-App URL')
|
||||
->maxLength(255)
|
||||
->placeholder('{{ route("invoices.show", $invoice) }}')
|
||||
->hint('Supports dynamic variables like {{ route("invoices.show", $invoice) }}')
|
||||
->disabled(fn (Get $get) => $get('in_app_enabled') === NotificationEnabledStatus::Never->value)
|
||||
->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' => ListNotificationTemplates::route('/'),
|
||||
'create' => CreateNotificationTemplate::route('/create'),
|
||||
'edit' => EditNotificationTemplate::route('/{record}/edit'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
namespace App\Admin\Resources\NotificationTemplateResource\Pages;
|
||||
|
||||
use App\Admin\Resources\NotificationTemplateResource;
|
||||
use Filament\Resources\Pages\CreateRecord;
|
||||
|
||||
class CreateNotificationTemplate extends CreateRecord
|
||||
{
|
||||
protected static string $resource = NotificationTemplateResource::class;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace App\Admin\Resources\NotificationTemplateResource\Pages;
|
||||
|
||||
use App\Admin\Resources\NotificationTemplateResource;
|
||||
use Filament\Actions\DeleteAction;
|
||||
use Filament\Resources\Pages\EditRecord;
|
||||
|
||||
class EditNotificationTemplate extends EditRecord
|
||||
{
|
||||
protected static string $resource = NotificationTemplateResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
DeleteAction::make(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace App\Admin\Resources\NotificationTemplateResource\Pages;
|
||||
|
||||
use App\Admin\Resources\NotificationTemplateResource;
|
||||
use Filament\Actions\CreateAction;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
|
||||
class ListNotificationTemplates extends ListRecords
|
||||
{
|
||||
protected static string $resource = NotificationTemplateResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
CreateAction::make(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -132,7 +132,7 @@ class ProductResource extends Resource
|
||||
->live()
|
||||
->afterStateUpdated(fn (Select $component) => $component
|
||||
->getContainer()
|
||||
->getComponent('extension_settings')
|
||||
->getComponent('extension_settings', withHidden: true)
|
||||
->getChildSchema()
|
||||
->fill()),
|
||||
|
||||
@@ -320,7 +320,6 @@ class ProductResource extends Resource
|
||||
return $query
|
||||
->orderBy('sort', 'asc');
|
||||
})
|
||||
->reorderable('sort')
|
||||
->defaultGroup('category.name');
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ use Filament\Schemas\Schema;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\HtmlString;
|
||||
|
||||
class ServiceCancellationResource extends Resource
|
||||
{
|
||||
@@ -38,6 +39,7 @@ class ServiceCancellationResource extends Resource
|
||||
->searchable()
|
||||
->preload()
|
||||
->disabledOn('edit')
|
||||
->hint(fn ($get) => $get('service_id') ? new HtmlString('<a href="' . ServiceResource::getUrl('edit', ['record' => $get('service_id')]) . '" target="_blank">Go to Service</a>') : null)
|
||||
->required(),
|
||||
TextInput::make('reason')
|
||||
->maxLength(255)
|
||||
@@ -57,15 +59,12 @@ class ServiceCancellationResource extends Resource
|
||||
return $table
|
||||
->columns([
|
||||
TextColumn::make('service_id')
|
||||
->numeric()
|
||||
->formatStateUsing(fn ($record) => $record->service->product->name . ' - ' . $record->service->plan->name . ' #' . $record->service->id . ($record->service->order->user ? ' (' . $record->service->order->user->email . ')' : ''))
|
||||
->url(fn ($record) => ServiceResource::getUrl('edit', ['record' => $record->service_id]))
|
||||
->sortable(),
|
||||
TextColumn::make('reason')
|
||||
->searchable(),
|
||||
TextColumn::make('type'),
|
||||
TextColumn::make('deleted_at')
|
||||
->dateTime()
|
||||
->sortable()
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
TextColumn::make('created_at')
|
||||
->dateTime()
|
||||
->sortable()
|
||||
@@ -85,7 +84,8 @@ class ServiceCancellationResource extends Resource
|
||||
BulkActionGroup::make([
|
||||
DeleteBulkAction::make(),
|
||||
]),
|
||||
]);
|
||||
])
|
||||
->defaultSort('created_at', 'desc');
|
||||
}
|
||||
|
||||
public static function getRelations(): array
|
||||
|
||||
@@ -90,12 +90,13 @@ class ServiceResource extends Resource
|
||||
->placeholder('Enter the quantity'),
|
||||
DatePicker::make('expires_at')
|
||||
->label('Expires At')
|
||||
->required()
|
||||
->required(fn (Get $get) => $get('plan')?->type != 'one-time' && $get('plan')?->type != 'free' && $get('status') !== 'pending')
|
||||
->placeholder('Select the expiration date'),
|
||||
Select::make('coupon_id')
|
||||
->label('Coupon')
|
||||
->relationship('coupon', 'code')
|
||||
->searchable()
|
||||
->preload()
|
||||
->placeholder('Select the coupon'),
|
||||
Select::make('currency_code')
|
||||
->options(function (Get $get, ?string $state) {
|
||||
@@ -127,9 +128,30 @@ class ServiceResource extends Resource
|
||||
JS
|
||||
))
|
||||
->numeric()
|
||||
->minValue(0),
|
||||
->minValue(0)
|
||||
->hintAction(
|
||||
Action::make('Recalculate Price')
|
||||
->action(function (Component $component, Service $service) {
|
||||
if ($service) {
|
||||
Notification::make('Price Recalculated')
|
||||
->title('The price has been successfully recalculated')
|
||||
->success()
|
||||
->send();
|
||||
// Update the form field
|
||||
$component->state($service->calculatePrice());
|
||||
}
|
||||
})
|
||||
->label('Recalculate Price')
|
||||
->icon('ri-refresh-line')
|
||||
),
|
||||
Select::make('billing_agreement_id')
|
||||
->label('Billing Agreement')
|
||||
->relationship('billingAgreement', 'name', fn (Builder $query, Get $get) => $query->where('user_id', $get('user_id')))
|
||||
->searchable()
|
||||
->preload()
|
||||
->placeholder('Select the billing agreement'),
|
||||
TextInput::make('subscription_id')
|
||||
->label('Subscription ID')
|
||||
->label('Subscription ID (deprecated)')
|
||||
->nullable()
|
||||
->placeholder('Enter the subscription ID')
|
||||
->hintAction(
|
||||
|
||||
@@ -14,7 +14,6 @@ use Filament\Forms\Components\Checkbox;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Resources\Pages\EditRecord;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class EditService extends EditRecord
|
||||
{
|
||||
@@ -23,7 +22,35 @@ class EditService extends EditRecord
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
DeleteAction::make(),
|
||||
DeleteAction::make()
|
||||
->form(function (DeleteAction $action) {
|
||||
$status = !in_array($this->record->status, [Service::STATUS_PENDING, Service::STATUS_CANCELLED]) && $this->record->product->server_id !== null;
|
||||
if (!$status) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
Checkbox::make('deleteExtensionServer')
|
||||
->label('Also trigger deletion of server')
|
||||
->default(true),
|
||||
];
|
||||
})
|
||||
->action(function (array $data, Service $record): void {
|
||||
try {
|
||||
if (($data['deleteExtensionServer'] ?? false)) {
|
||||
ExtensionHelper::terminateServer($record);
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
report($e);
|
||||
|
||||
Notification::make('Error')
|
||||
->title('Error occured while deleting the related server:')
|
||||
->body($e->getMessage())
|
||||
->danger()
|
||||
->send();
|
||||
}
|
||||
$record->delete();
|
||||
}),
|
||||
Action::make('changeStatus')
|
||||
->label('Trigger Extension Action')
|
||||
->schema([
|
||||
@@ -66,7 +93,7 @@ class EditService extends EditRecord
|
||||
if (config('app.debug')) {
|
||||
throw $e;
|
||||
}
|
||||
Log::error($e);
|
||||
report($e);
|
||||
Notification::make('Error')
|
||||
->title('Error occured while triggering the action:')
|
||||
->body($e->getMessage())
|
||||
@@ -82,6 +109,7 @@ class EditService extends EditRecord
|
||||
})
|
||||
->color('primary')
|
||||
->modalSubmitActionLabel('Trigger'),
|
||||
|
||||
AuditAction::make()->auditChildren([
|
||||
'order',
|
||||
'invoices',
|
||||
@@ -91,4 +119,15 @@ class EditService extends EditRecord
|
||||
]),
|
||||
];
|
||||
}
|
||||
|
||||
protected function getHeaderWidgets(): array
|
||||
{
|
||||
if (!$this->record->cancellation()->exists()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
ServiceResource\Widgets\CancellationOverview::class,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
namespace App\Admin\Resources\ServiceResource\Widgets;
|
||||
|
||||
use App\Models\Service;
|
||||
use Filament\Widgets\Widget;
|
||||
|
||||
class CancellationOverview extends Widget
|
||||
{
|
||||
protected string $view = 'admin.resources.service-resource.widgets.cancellation-overview';
|
||||
|
||||
protected static bool $isLazy = false;
|
||||
|
||||
public ?Service $record = null;
|
||||
|
||||
protected array|string|int $columnSpan = 'full';
|
||||
}
|
||||
@@ -8,6 +8,7 @@ use App\Admin\Resources\TicketResource\Pages\EditTicket;
|
||||
use App\Admin\Resources\TicketResource\Pages\ListTickets;
|
||||
use App\Admin\Resources\TicketResource\Widgets\TicketsOverView;
|
||||
use App\Models\Ticket;
|
||||
use App\Models\User;
|
||||
use Filament\Actions\BulkActionGroup;
|
||||
use Filament\Actions\DeleteBulkAction;
|
||||
use Filament\Actions\EditAction;
|
||||
@@ -18,6 +19,7 @@ use Filament\Resources\Resource;
|
||||
use Filament\Schemas\Components\Utilities\Get;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Filters\SelectFilter;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Contracts\Support\Htmlable;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
@@ -130,6 +132,9 @@ class TicketResource extends Resource
|
||||
{
|
||||
return $table
|
||||
->columns([
|
||||
TextColumn::make('id')
|
||||
->label('ID')
|
||||
->sortable(),
|
||||
TextColumn::make('subject')
|
||||
->searchable()
|
||||
->sortable(),
|
||||
@@ -152,14 +157,30 @@ class TicketResource extends Resource
|
||||
})
|
||||
->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([
|
||||
//
|
||||
SelectFilter::make('user')
|
||||
->label('User')
|
||||
->relationship('user', 'id')
|
||||
->indicateUsing(fn ($data) => $data['value'] ? 'User: ' . User::find($data['value'])->name : null)
|
||||
->getOptionLabelFromRecordUsing(fn ($record) => $record->name),
|
||||
SelectFilter::make('priority')
|
||||
->options([
|
||||
'low' => 'Low',
|
||||
'medium' => 'Medium',
|
||||
'high' => 'High',
|
||||
]),
|
||||
SelectFilter::make('department')
|
||||
->options(array_combine(config('settings.ticket_departments'), config('settings.ticket_departments')), config('settings.ticket_departments')),
|
||||
SelectFilter::make('assigned_to')
|
||||
->label('Assigned To')
|
||||
->relationship('user', 'id', fn (Builder $query) => $query->where('role_id', '!=', null))
|
||||
->indicateUsing(fn ($data) => $data['value'] ? 'Assigned to: ' . User::find($data['value'])->name : null)
|
||||
->getOptionLabelFromRecordUsing(fn ($record) => $record->name),
|
||||
])
|
||||
->recordActions([
|
||||
EditAction::make(),
|
||||
|
||||
@@ -55,6 +55,23 @@ class EditTicket extends EditRecord
|
||||
]);
|
||||
}
|
||||
|
||||
public function closeTicket(): Action
|
||||
{
|
||||
return Action::make('closeTicket')
|
||||
->label(__('ticket.close_ticket'))
|
||||
->color('danger')
|
||||
->hidden(!auth()->user()->can('update', $this->record) || $this->record->status === 'closed')
|
||||
->action(fn (Ticket $record) => $record->update(['status' => 'closed']))
|
||||
->after(function () {
|
||||
$this->record->refresh();
|
||||
|
||||
Notification::make()
|
||||
->title('Ticket closed successfully.')
|
||||
->success()
|
||||
->send();
|
||||
});
|
||||
}
|
||||
|
||||
// Save action
|
||||
public function send()
|
||||
{
|
||||
@@ -119,7 +136,6 @@ class EditTicket extends EditRecord
|
||||
->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'),
|
||||
|
||||
|
||||
@@ -6,9 +6,11 @@ use App\Admin\Resources\Common\RelationManagers\PropertiesRelationManager;
|
||||
use App\Admin\Resources\UserResource\Pages\CreateUser;
|
||||
use App\Admin\Resources\UserResource\Pages\EditUser;
|
||||
use App\Admin\Resources\UserResource\Pages\ListUsers;
|
||||
use App\Admin\Resources\UserResource\Pages\ShowBillingAgreements;
|
||||
use App\Admin\Resources\UserResource\Pages\ShowCredits;
|
||||
use App\Admin\Resources\UserResource\Pages\ShowInvoices;
|
||||
use App\Admin\Resources\UserResource\Pages\ShowServices;
|
||||
use App\Admin\Resources\UserResource\Pages\ShowTickets;
|
||||
use App\Models\Credit;
|
||||
use App\Models\User;
|
||||
use Filament\Actions\EditAction;
|
||||
@@ -19,6 +21,7 @@ use Filament\Resources\Pages\Page;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Filters\Filter;
|
||||
use Filament\Tables\Filters\SelectFilter;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Contracts\Support\Htmlable;
|
||||
@@ -104,6 +107,13 @@ class UserResource extends Resource
|
||||
->relationship('role', 'name')
|
||||
->searchable()
|
||||
->preload(),
|
||||
Filter::make('email_verified')
|
||||
->label('Email Verified'),
|
||||
Filter::make('has_active_services')
|
||||
->label('Has Active Services')
|
||||
->query(fn ($query) => $query->whereHas('services', function ($q) {
|
||||
$q->where('status', 'active');
|
||||
})),
|
||||
])
|
||||
->recordActions([
|
||||
EditAction::make(),
|
||||
@@ -127,17 +137,20 @@ class UserResource extends Resource
|
||||
'services' => ShowServices::route('/{record}/services'),
|
||||
'invoices' => ShowInvoices::route('/{record}/invoices'),
|
||||
'credits' => ShowCredits::route('/{record}/credits'),
|
||||
'tickets' => ShowTickets::route('/{record}/tickets'),
|
||||
'billing-agreements' => ShowBillingAgreements::route('/{record}/billing-agreements'),
|
||||
];
|
||||
}
|
||||
|
||||
public static function getRecordSubNavigation(Page $page): array
|
||||
{
|
||||
|
||||
return $page->generateNavigationItems([
|
||||
EditUser::class,
|
||||
ShowServices::class,
|
||||
ShowInvoices::class,
|
||||
ShowCredits::class,
|
||||
ShowTickets::class,
|
||||
ShowBillingAgreements::class,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,6 +31,8 @@ class EditUser extends EditRecord
|
||||
'tickets',
|
||||
'credits',
|
||||
'orders',
|
||||
'billingAgreements',
|
||||
'properties',
|
||||
]),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
namespace App\Admin\Resources\UserResource\Pages;
|
||||
|
||||
use App\Admin\Resources\GatewayResource;
|
||||
use App\Admin\Resources\UserResource;
|
||||
use Filament\Resources\Pages\ManageRelatedRecords;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Table;
|
||||
|
||||
class ShowBillingAgreements extends ManageRelatedRecords
|
||||
{
|
||||
protected static string $resource = UserResource::class;
|
||||
|
||||
protected static string $relationship = 'billingAgreements';
|
||||
|
||||
protected static string|\BackedEnum|null $navigationIcon = 'ri-bank-card-line';
|
||||
|
||||
public static function getNavigationLabel(): string
|
||||
{
|
||||
return 'Billing Agreements';
|
||||
}
|
||||
|
||||
public function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->recordTitleAttribute('name')
|
||||
->columns([
|
||||
TextColumn::make('name')
|
||||
->label('Name'),
|
||||
TextColumn::make('external_reference')
|
||||
->label('External Reference'),
|
||||
TextColumn::make('gateway.name')
|
||||
->url(fn ($record) => GatewayResource::getUrl('edit', ['record' => $record->gateway_id]))
|
||||
->label('Gateway'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -22,7 +22,7 @@ class ShowCredits extends ManageRelatedRecords
|
||||
|
||||
protected static string $relationship = 'credits';
|
||||
|
||||
protected static string|\BackedEnum|null $navigationIcon = 'ri-bill-line';
|
||||
protected static string|\BackedEnum|null $navigationIcon = 'ri-coin-line';
|
||||
|
||||
public static function getNavigationLabel(): string
|
||||
{
|
||||
|
||||
@@ -16,7 +16,7 @@ class ShowInvoices extends ManageRelatedRecords
|
||||
|
||||
protected static string $relationship = 'invoices';
|
||||
|
||||
protected static string|\BackedEnum|null $navigationIcon = 'ri-bill-line';
|
||||
protected static string|\BackedEnum|null $navigationIcon = 'ri-receipt-line';
|
||||
|
||||
public static function getNavigationLabel(): string
|
||||
{
|
||||
|
||||
57
app/Admin/Resources/UserResource/Pages/ShowTickets.php
Normal file
57
app/Admin/Resources/UserResource/Pages/ShowTickets.php
Normal file
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
namespace App\Admin\Resources\UserResource\Pages;
|
||||
|
||||
use App\Admin\Resources\UserResource;
|
||||
use App\Models\Ticket;
|
||||
use Filament\Resources\Pages\ManageRelatedRecords;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Table;
|
||||
|
||||
class ShowTickets extends ManageRelatedRecords
|
||||
{
|
||||
protected static string $resource = UserResource::class;
|
||||
|
||||
protected static string $relationship = 'tickets';
|
||||
|
||||
protected static string|\BackedEnum|null $navigationIcon = 'ri-customer-service-line';
|
||||
|
||||
public static function getNavigationLabel(): string
|
||||
{
|
||||
return 'Tickets';
|
||||
}
|
||||
|
||||
public function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->recordTitleAttribute('name')
|
||||
->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')
|
||||
->sortable(),
|
||||
TextColumn::make('user.name')
|
||||
->searchable(['first_name', 'last_name'])
|
||||
->sortable(['first_name', 'last_name']),
|
||||
]);
|
||||
}
|
||||
}
|
||||
41
app/Admin/Widgets/CronStat/CronOverview.php
Normal file
41
app/Admin/Widgets/CronStat/CronOverview.php
Normal file
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
namespace App\Admin\Widgets\CronStat;
|
||||
|
||||
use App\Models\Setting;
|
||||
use Carbon\Carbon;
|
||||
use Filament\Widgets\StatsOverviewWidget;
|
||||
use Filament\Widgets\StatsOverviewWidget\Stat;
|
||||
|
||||
class CronOverview extends StatsOverviewWidget
|
||||
{
|
||||
protected static bool $isDiscovered = false;
|
||||
|
||||
protected ?string $pollingInterval = '60s';
|
||||
|
||||
protected function getStats(): array
|
||||
{
|
||||
$lastRun = Setting::where('key', 'last_scheduler_run')->first()?->value;
|
||||
$lastCronRun = Setting::where('key', 'last_cron_run')->first()?->value;
|
||||
|
||||
$cronTime = config('settings.cronjob_time', '00:00');
|
||||
$now = Carbon::now();
|
||||
$nextRun = $now->copy()->setTimeFromTimeString($cronTime);
|
||||
|
||||
if ($nextRun->lessThanOrEqualTo($now)) {
|
||||
$nextRun->addDay();
|
||||
}
|
||||
|
||||
return [
|
||||
Stat::make('Last scheduler run', $lastRun ? Carbon::parse($lastRun)->diffForHumans() : 'Never')
|
||||
->extraAttributes([
|
||||
'class' => $lastRun && Carbon::parse($lastRun)->gt(Carbon::now()->subMinutes(5)) ? 'success' : 'error',
|
||||
]),
|
||||
Stat::make('Last cron run', $lastCronRun ? Carbon::parse($lastCronRun)->diffForHumans() : 'Never')
|
||||
->extraAttributes([
|
||||
'class' => $lastCronRun && Carbon::parse($lastCronRun)->gt(Carbon::now()->subHours(24)) ? 'success' : 'error',
|
||||
]),
|
||||
Stat::make('Next cron run', $nextRun->diffForHumans()),
|
||||
];
|
||||
}
|
||||
}
|
||||
40
app/Admin/Widgets/CronStat/CronStat.php
Normal file
40
app/Admin/Widgets/CronStat/CronStat.php
Normal file
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
namespace App\Admin\Widgets\CronStat;
|
||||
|
||||
use Carbon\Carbon;
|
||||
use Filament\Widgets\Concerns\InteractsWithPageFilters;
|
||||
use Filament\Widgets\StatsOverviewWidget;
|
||||
use Filament\Widgets\StatsOverviewWidget\Stat;
|
||||
|
||||
class CronStat extends StatsOverviewWidget
|
||||
{
|
||||
use InteractsWithPageFilters;
|
||||
|
||||
protected static bool $isDiscovered = false;
|
||||
|
||||
protected ?string $pollingInterval = null;
|
||||
|
||||
public function getColumns(): int|array
|
||||
{
|
||||
return 2;
|
||||
}
|
||||
|
||||
protected function getStats(): array
|
||||
{
|
||||
$date = $this->pageFilters['date'] ?? now()->toDateString();
|
||||
|
||||
return [
|
||||
Stat::make('Invoices Created', \App\Models\CronStat::where('key', 'invoices_created')->where('date', $date)->sum('value'))
|
||||
->description('Total renewal invoices created on ' . Carbon::parse($date)->toFormattedDateString()),
|
||||
Stat::make('Services Suspended', \App\Models\CronStat::where('key', 'services_suspended')->where('date', $date)->sum('value'))
|
||||
->description('Total overdue services suspended on ' . Carbon::parse($date)->toFormattedDateString()),
|
||||
Stat::make('Services Terminated', \App\Models\CronStat::where('key', 'services_terminated')->where('date', $date)->sum('value'))
|
||||
->description('Total overdue services terminated on ' . Carbon::parse($date)->toFormattedDateString()),
|
||||
Stat::make('Tickets Closed', \App\Models\CronStat::where('key', 'tickets_closed')->where('date', $date)->sum('value'))
|
||||
->description('Total inactive tickets closed on ' . Carbon::parse($date)->toFormattedDateString()),
|
||||
Stat::make('Invoices Charged', \App\Models\CronStat::where('key', 'invoice_charged')->where('date', $date)->sum('value'))
|
||||
->description('Total invoices charged on ' . Carbon::parse($date)->toFormattedDateString()),
|
||||
];
|
||||
}
|
||||
}
|
||||
113
app/Admin/Widgets/CronStat/CronTable.php
Normal file
113
app/Admin/Widgets/CronStat/CronTable.php
Normal file
@@ -0,0 +1,113 @@
|
||||
<?php
|
||||
|
||||
namespace App\Admin\Widgets\CronStat;
|
||||
|
||||
use App\Models\CronStat;
|
||||
use Filament\Widgets\ChartWidget;
|
||||
use Flowframe\Trend\Trend;
|
||||
use Flowframe\Trend\TrendValue;
|
||||
|
||||
class CronTable extends ChartWidget
|
||||
{
|
||||
protected static bool $isDiscovered = false;
|
||||
|
||||
protected int|string|array $columnSpan = 'full';
|
||||
|
||||
protected ?string $pollingInterval = null;
|
||||
|
||||
protected static bool $isLazy = true;
|
||||
|
||||
public ?string $filter = 'week';
|
||||
|
||||
protected ?string $maxHeight = '300px';
|
||||
|
||||
protected ?string $heading = 'Cron Table';
|
||||
|
||||
// Start at zero (chartjs option)
|
||||
protected ?array $options = [
|
||||
'scales' => [
|
||||
'y' => [
|
||||
'beginAtZero' => true,
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
protected function getData(): array
|
||||
{
|
||||
$activeFilter = $this->filter;
|
||||
$start = match ($activeFilter) {
|
||||
'today' => now()->subDay(),
|
||||
'week' => now()->subWeek(),
|
||||
'month' => now()->subMonth(),
|
||||
'year' => now()->subYear(),
|
||||
};
|
||||
$end = now();
|
||||
|
||||
$invoicesCreated = $this->addDefault(Trend::query(CronStat::query()->where('key', 'invoices_created')), $start, $end);
|
||||
$servicesSuspended = $this->addDefault(Trend::query(CronStat::query()->where('key', 'services_suspended')), $start, $end);
|
||||
$servicesTerminated = $this->addDefault(Trend::query(CronStat::query()->where('key', 'services_terminated')), $start, $end);
|
||||
$invoicesCharged = $this->addDefault(Trend::query(CronStat::query()->where('key', 'invoice_charged')), $start, $end);
|
||||
|
||||
return [
|
||||
'datasets' => [
|
||||
[
|
||||
'label' => 'Invoices Created',
|
||||
|
||||
'data' => $invoicesCreated->map(fn (TrendValue $value) => $value->aggregate),
|
||||
'backgroundColor' => 'rgba(54, 162, 235, 0.5)',
|
||||
'borderColor' => 'rgba(54, 162, 235, 1)',
|
||||
'borderWidth' => 1,
|
||||
],
|
||||
[
|
||||
'label' => 'Services Suspended',
|
||||
'data' => $servicesSuspended->map(fn (TrendValue $value) => $value->aggregate),
|
||||
'backgroundColor' => 'rgba(255, 206, 86, 0.5)',
|
||||
'borderColor' => 'rgba(255, 206, 86, 1)',
|
||||
'borderWidth' => 1,
|
||||
],
|
||||
[
|
||||
'label' => 'Services Terminated',
|
||||
'data' => $servicesTerminated->map(fn (TrendValue $value) => $value->aggregate),
|
||||
'backgroundColor' => 'rgba(255, 99, 132, 0.5)',
|
||||
'borderColor' => 'rgba(255, 99, 132, 1)',
|
||||
'borderWidth' => 1,
|
||||
],
|
||||
[
|
||||
'label' => 'Invoices Charged',
|
||||
'data' => $invoicesCharged->map(fn (TrendValue $value) => $value->aggregate),
|
||||
'backgroundColor' => 'rgba(75, 192, 192, 0.5)',
|
||||
'borderColor' => 'rgba(75, 192, 192, 1)',
|
||||
'borderWidth' => 1,
|
||||
],
|
||||
],
|
||||
'labels' => $invoicesCreated->map(fn (TrendValue $value) => $value->date)->toArray(),
|
||||
];
|
||||
}
|
||||
|
||||
private function addDefault(Trend $data, $start, $end)
|
||||
{
|
||||
return $data
|
||||
->dateColumn('date')
|
||||
->between(
|
||||
start: $start,
|
||||
end: $end,
|
||||
)
|
||||
->perDay()
|
||||
->sum('value');
|
||||
}
|
||||
|
||||
protected function getFilters(): ?array
|
||||
{
|
||||
return [
|
||||
'today' => 'Today',
|
||||
'week' => 'Last week',
|
||||
'month' => 'Last month',
|
||||
'year' => 'This year',
|
||||
];
|
||||
}
|
||||
|
||||
protected function getType(): string
|
||||
{
|
||||
return 'line';
|
||||
}
|
||||
}
|
||||
@@ -58,7 +58,7 @@ class Overview extends BaseWidget
|
||||
$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)')
|
||||
->description($increase >= 0 ? 'Increased by ' . number_format($percentageIncrease, 2) . '% (last 30 days)' : 'Decreased by ' . number_format($percentageIncrease, 2) . '% (last 30 days)')
|
||||
->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');
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Admin\Widgets;
|
||||
|
||||
use App\Enums\InvoiceTransactionStatus;
|
||||
use App\Models\InvoiceTransaction;
|
||||
use App\Models\Order;
|
||||
use Carbon\Carbon;
|
||||
@@ -46,7 +47,7 @@ class Revenue extends ChartWidget
|
||||
'year' => 'month',
|
||||
};
|
||||
|
||||
$revenue = Trend::model(InvoiceTransaction::class)
|
||||
$revenue = Trend::query(InvoiceTransaction::query()->where('status', InvoiceTransactionStatus::Succeeded)->where('is_credit_transaction', false))
|
||||
->between(
|
||||
start: $start,
|
||||
end: $end,
|
||||
@@ -54,7 +55,7 @@ class Revenue extends ChartWidget
|
||||
->{'per' . ucfirst($per)}()
|
||||
->sum('amount');
|
||||
|
||||
$netRevenue = Trend::model(InvoiceTransaction::class)
|
||||
$netRevenue = Trend::query(InvoiceTransaction::query()->where('status', InvoiceTransactionStatus::Succeeded)->where('is_credit_transaction', false))
|
||||
->between(
|
||||
start: $start,
|
||||
end: $end,
|
||||
|
||||
@@ -12,5 +12,6 @@ class DisabledIf
|
||||
public function __construct(
|
||||
public string $setting,
|
||||
public bool $default = false,
|
||||
public bool $reverse = false
|
||||
) {}
|
||||
}
|
||||
|
||||
@@ -15,5 +15,6 @@ class ExtensionMeta
|
||||
public string $version,
|
||||
public string $author,
|
||||
public string $url = '',
|
||||
public string $icon = '',
|
||||
) {}
|
||||
}
|
||||
|
||||
@@ -4,62 +4,121 @@ namespace App\Classes;
|
||||
|
||||
use App\Exceptions\DisplayException;
|
||||
use App\Models\Coupon;
|
||||
use App\Models\Plan;
|
||||
use App\Models\Product;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Cookie;
|
||||
use Illuminate\Support\Facades\Session;
|
||||
|
||||
class Cart
|
||||
{
|
||||
public static function get()
|
||||
public static function getOnce()
|
||||
{
|
||||
return collect(session('cart', []));
|
||||
}
|
||||
|
||||
public static function add($product, $plan, $configOptions, $checkoutConfig, Price $price, $quantity = 1, $key = null)
|
||||
{
|
||||
if (isset($key)) {
|
||||
$cart = self::get();
|
||||
// Check if key exists
|
||||
$cart[$key] = (object) [
|
||||
'product' => (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,
|
||||
]);
|
||||
if (!Cookie::has('cart') || !$cart = \App\Models\Cart::where('ulid', Cookie::get('cart'))->first()) {
|
||||
return new \App\Models\Cart;
|
||||
}
|
||||
|
||||
session(['cart' => $cart]);
|
||||
return $cart->load('items.plan', 'items.product', 'items.product.configOptions.children.plans.prices');
|
||||
}
|
||||
|
||||
if (Session::has('coupon')) {
|
||||
public static function get()
|
||||
{
|
||||
return once(fn () => self::getOnce());
|
||||
}
|
||||
|
||||
public static function clear()
|
||||
{
|
||||
if (Cookie::has('cart')) {
|
||||
\App\Models\Cart::where('ulid', Cookie::get('cart'))->delete();
|
||||
Cookie::queue(Cookie::forget('cart'));
|
||||
}
|
||||
}
|
||||
|
||||
public static function items()
|
||||
{
|
||||
return self::get()->items;
|
||||
}
|
||||
|
||||
public static function createCart()
|
||||
{
|
||||
$cart = self::getOnce();
|
||||
if (!$cart->exists) {
|
||||
$cart->user_id = Auth::id();
|
||||
$cart->currency_code = session('currency', session('currency', config('settings.default_currency')));
|
||||
$cart->save();
|
||||
Cookie::queue('cart', $cart->ulid, 60 * 24 * 30); // 30 days
|
||||
$cart = \App\Models\Cart::find($cart->id);
|
||||
}
|
||||
|
||||
return $cart;
|
||||
}
|
||||
|
||||
public static function add(Product $product, Plan $plan, $configOptions, $checkoutConfig, $quantity = 1, $key = null)
|
||||
{
|
||||
// Match on key
|
||||
$cart = self::createCart();
|
||||
|
||||
$item = $cart->items()->updateOrCreate([
|
||||
'id' => $key,
|
||||
], [
|
||||
'product_id' => $product->id,
|
||||
'plan_id' => $plan->id,
|
||||
'config_options' => $configOptions,
|
||||
'checkout_config' => $checkoutConfig,
|
||||
'quantity' => $quantity,
|
||||
]);
|
||||
$cart->load('items.plan', 'items.product', 'items.product.configOptions.children.plans.prices');
|
||||
|
||||
if ($cart->coupon_id) {
|
||||
// Reapply coupon to the cart
|
||||
try {
|
||||
$coupon = Session::get('coupon');
|
||||
self::removeCoupon();
|
||||
self::applyCoupon($coupon->code);
|
||||
self::validateCoupon($cart->coupon->code);
|
||||
// Check if any of the items have gotten a discount
|
||||
if ($cart->items->filter(fn ($item) => $item->price->hasDiscount())->isEmpty()) {
|
||||
$cart->coupon_id = null;
|
||||
$cart->save();
|
||||
}
|
||||
} catch (DisplayException $e) {
|
||||
// Ignore exception
|
||||
// Coupon is invalid, remove it
|
||||
$cart->coupon_id = null;
|
||||
$cart->save();
|
||||
}
|
||||
}
|
||||
|
||||
// Return index of the newly added item
|
||||
return $key ?? $cart->count() - 1;
|
||||
return $item->id;
|
||||
}
|
||||
|
||||
public static function remove($index)
|
||||
{
|
||||
$cart = self::get();
|
||||
$cart->forget($index);
|
||||
session(['cart' => $cart]);
|
||||
$item = $cart->items()->where('id', $index)->first();
|
||||
if ($item) {
|
||||
$item->delete(); // We also want to trigger Eloquent events
|
||||
}
|
||||
$cart->load('items.plan', 'items.product', 'items.product.configOptions.children.plans.prices');
|
||||
}
|
||||
|
||||
public static function updateQuantity($index, $quantity)
|
||||
{
|
||||
$cart = self::get();
|
||||
if ($item = $cart->items()->where('id', $index)->first()) {
|
||||
if ($item->product->allow_quantity !== 'combined') {
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($quantity < 1) {
|
||||
self::remove($index);
|
||||
|
||||
return;
|
||||
}
|
||||
$item->quantity = $quantity;
|
||||
$item->save();
|
||||
|
||||
$cart->load('items');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -99,35 +158,22 @@ class Cart
|
||||
$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;
|
||||
}
|
||||
$cart = self::createCart();
|
||||
$cart->coupon_id = $coupon->id;
|
||||
$cart->save();
|
||||
|
||||
// Check if any of the items have gotten a discount, if empty also set succesful because it's valid for future use (will get rechecked on checkout
|
||||
if ($cart->items->filter(fn ($item) => $item->price->hasDiscount())->isNotEmpty() || $cart->items->isEmpty()) {
|
||||
$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]);
|
||||
} else {
|
||||
$cart->coupon_id = null;
|
||||
}
|
||||
|
||||
if ($wasSuccessful) {
|
||||
return $items;
|
||||
$cart->save();
|
||||
} else {
|
||||
$cart->coupon_id = null;
|
||||
$cart->save();
|
||||
throw new DisplayException('Coupon code is not valid for any items in your cart');
|
||||
}
|
||||
}
|
||||
@@ -139,12 +185,12 @@ class Cart
|
||||
*/
|
||||
public static function validateAndRefreshCoupon()
|
||||
{
|
||||
if (!Session::has('coupon')) {
|
||||
if (!self::get()->coupon_id || !self::get()->coupon) {
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
$coupon = Session::get('coupon');
|
||||
$coupon = self::get()->coupon;
|
||||
self::validateCoupon($coupon->code);
|
||||
|
||||
return true;
|
||||
@@ -158,16 +204,7 @@ class Cart
|
||||
|
||||
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]);
|
||||
self::get()->update(['coupon_id' => null]);
|
||||
self::get()->load('coupon');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,20 +84,4 @@ class Extension
|
||||
* @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() {}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,62 @@
|
||||
|
||||
namespace App\Classes\Extension;
|
||||
|
||||
use App\Models\BillingAgreement;
|
||||
use App\Models\Card;
|
||||
use App\Models\Invoice;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\View;
|
||||
|
||||
/**
|
||||
* Class Gateway
|
||||
*/
|
||||
class Gateway extends Extension {}
|
||||
abstract class Gateway extends Extension
|
||||
{
|
||||
/**
|
||||
* Pay the given invoice with the given total amount.
|
||||
*
|
||||
* @param mixed $total
|
||||
* @return View|string
|
||||
*/
|
||||
abstract public function pay(Invoice $invoice, $total);
|
||||
|
||||
/**
|
||||
* Check if gateway supports billing agreements.
|
||||
*/
|
||||
public function supportsBillingAgreements(): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a billing agreement for the given user.
|
||||
*
|
||||
* @param string $currencyCode
|
||||
* @return View|string
|
||||
*/
|
||||
public function createBillingAgreement(User $user)
|
||||
{
|
||||
throw new \Exception('Not implemented');
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel the billing agreement associated with the given card.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function cancelBillingAgreement(BillingAgreement $billingAgreement): bool
|
||||
{
|
||||
throw new \Exception('Not implemented');
|
||||
}
|
||||
|
||||
/**
|
||||
* Charge the given billing agreement for the given invoice and amount.
|
||||
*
|
||||
* @param mixed $total
|
||||
* @return bool
|
||||
*/
|
||||
public function charge(Invoice $invoice, $total, BillingAgreement $billingAgreement)
|
||||
{
|
||||
throw new \Exception('Not implemented');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ class Navigation
|
||||
$routes = [
|
||||
[
|
||||
'name' => __('navigation.home'),
|
||||
'route' => 'home',
|
||||
'url' => route('home'),
|
||||
'icon' => 'ri-home-2',
|
||||
],
|
||||
[
|
||||
@@ -29,8 +29,7 @@ class Navigation
|
||||
'children' => $categories->map(function ($category) {
|
||||
return [
|
||||
'name' => $category->name,
|
||||
'route' => 'category.show',
|
||||
'params' => ['category' => $category->slug],
|
||||
'url' => route('category.show', ['category' => $category->slug]),
|
||||
];
|
||||
})->toArray(),
|
||||
'condition' => count($categories) > 0,
|
||||
@@ -57,22 +56,22 @@ class Navigation
|
||||
$routes = [
|
||||
[
|
||||
'name' => __('navigation.dashboard'),
|
||||
'route' => 'dashboard',
|
||||
'url' => route('dashboard'),
|
||||
],
|
||||
[
|
||||
'name' => __('navigation.tickets'),
|
||||
'route' => 'tickets',
|
||||
'url' => route('tickets'),
|
||||
'condition' => !config('settings.tickets_disabled', false),
|
||||
],
|
||||
[
|
||||
'name' => __('navigation.account'),
|
||||
'route' => 'account',
|
||||
'url' => route('account'),
|
||||
],
|
||||
[
|
||||
'name' => __('navigation.admin'),
|
||||
'route' => 'filament.admin.pages.dashboard',
|
||||
'url' => route('filament.admin.pages.dashboard'),
|
||||
'spa' => false,
|
||||
'condition' => Auth::user()->role_id !== null,
|
||||
'condition' => Auth::check() && Auth::user()->role_id !== null,
|
||||
],
|
||||
];
|
||||
|
||||
@@ -93,21 +92,21 @@ class Navigation
|
||||
$routes = [
|
||||
[
|
||||
'name' => __('navigation.dashboard'),
|
||||
'route' => 'dashboard',
|
||||
'url' => route('dashboard'),
|
||||
'icon' => 'ri-function',
|
||||
'condition' => Auth::check(),
|
||||
'priority' => 10,
|
||||
],
|
||||
[
|
||||
'name' => __('navigation.services'),
|
||||
'route' => 'services',
|
||||
'url' => route('services'),
|
||||
'icon' => 'ri-archive-stack',
|
||||
'condition' => Auth::check(),
|
||||
'priority' => 20,
|
||||
],
|
||||
[
|
||||
'name' => __('navigation.invoices'),
|
||||
'route' => 'invoices',
|
||||
'url' => route('invoices'),
|
||||
'icon' => 'ri-receipt',
|
||||
'separator' => true,
|
||||
'condition' => Auth::check(),
|
||||
@@ -115,7 +114,7 @@ class Navigation
|
||||
],
|
||||
[
|
||||
'name' => __('navigation.tickets'),
|
||||
'route' => 'tickets',
|
||||
'url' => route('tickets'),
|
||||
'icon' => 'ri-customer-service',
|
||||
'separator' => true,
|
||||
'condition' => Auth::check() && !config('settings.tickets_disabled', false),
|
||||
@@ -131,23 +130,33 @@ class Navigation
|
||||
[
|
||||
[
|
||||
'name' => __('navigation.personal_details'),
|
||||
'route' => 'account',
|
||||
'url' => route('account'),
|
||||
'params' => [],
|
||||
'priority' => 10,
|
||||
],
|
||||
[
|
||||
'name' => __('navigation.security'),
|
||||
'route' => 'account.security',
|
||||
'url' => route('account.security'),
|
||||
'params' => [],
|
||||
'priority' => 20,
|
||||
],
|
||||
[
|
||||
'name' => __('account.credits'),
|
||||
'route' => 'account.credits',
|
||||
'url' => route('account.credits'),
|
||||
'params' => [],
|
||||
'condition' => config('settings.credits_enabled'),
|
||||
'priority' => 30,
|
||||
],
|
||||
[
|
||||
'name' => __('account.payment_methods'),
|
||||
'url' => route('account.payment-methods'),
|
||||
'priority' => 40,
|
||||
],
|
||||
[
|
||||
'name' => __('navigation.notifications'),
|
||||
'url' => route('account.notifications'),
|
||||
'priority' => 50,
|
||||
],
|
||||
]
|
||||
),
|
||||
],
|
||||
@@ -199,7 +208,7 @@ class Navigation
|
||||
*/
|
||||
public static function markActiveRoute(array $routes): array
|
||||
{
|
||||
$currentRoute = request()->livewireRoute();
|
||||
$currentRoute = request()->livewireUrl();
|
||||
|
||||
foreach ($routes as &$route) {
|
||||
$route['active'] = self::isActiveRoute($route, $currentRoute);
|
||||
@@ -211,8 +220,22 @@ class Navigation
|
||||
if (isset($route['children'])) {
|
||||
foreach ($route['children'] as &$child) {
|
||||
$child['active'] = self::isActiveRoute($child, $currentRoute);
|
||||
|
||||
if (isset($child['icon'])) {
|
||||
$child['icon'] .= $child['active'] ? '-fill' : '-line';
|
||||
}
|
||||
|
||||
// Make route a url
|
||||
if (isset($child['route']) && !isset($child['url'])) {
|
||||
$child['url'] = route($child['route'], $child['params'] ?? []);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Make route a url
|
||||
if (isset($route['route']) && !isset($route['url'])) {
|
||||
$route['url'] = route($route['route'], $route['params'] ?? []);
|
||||
}
|
||||
}
|
||||
|
||||
return $routes;
|
||||
@@ -220,13 +243,13 @@ class Navigation
|
||||
|
||||
private static function isActiveRoute(array $route, string $currentRoute): bool
|
||||
{
|
||||
if (($route['route'] ?? '') === $currentRoute) {
|
||||
if (($route['url'] ?? '') === $currentRoute) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!empty($route['children'])) {
|
||||
foreach ($route['children'] as $child) {
|
||||
if (($child['route'] ?? '') === $currentRoute) {
|
||||
if (($child['url'] ?? '') === $currentRoute) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -234,28 +257,4 @@ class Navigation
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
|
||||
namespace App\Classes;
|
||||
|
||||
use App\Classes\Pdf\ContentPdfWrapper;
|
||||
use App\Classes\Pdf\FilePdfWrapper;
|
||||
use App\Events\Invoice\GeneratePdf;
|
||||
use App\Models\Invoice;
|
||||
use Barryvdh\DomPDF\Facade\Pdf as DomPDF;
|
||||
|
||||
@@ -9,8 +12,37 @@ class PDF
|
||||
{
|
||||
public static function generateInvoice(Invoice $invoice)
|
||||
{
|
||||
$pdf = DomPDF::loadView('pdf.invoice', ['invoice' => $invoice]);
|
||||
// Dispatch event to see if any extension wants to handle PDF generation
|
||||
$event = new GeneratePdf($invoice);
|
||||
event($event);
|
||||
|
||||
return $pdf;
|
||||
// If an extension provided a PDF in any format, handle it
|
||||
if ($event->hasPdf()) {
|
||||
return self::processPdfFromEvent($event);
|
||||
}
|
||||
|
||||
// Fall back to default PDF generation
|
||||
return DomPDF::loadView('pdf.invoice', ['invoice' => $invoice]);
|
||||
}
|
||||
|
||||
private static function processPdfFromEvent(GeneratePdf $event)
|
||||
{
|
||||
// If it's already a DomPDF instance, return it
|
||||
if ($event->pdf) {
|
||||
return $event->pdf;
|
||||
}
|
||||
|
||||
// If it's a file path, create a wrapper
|
||||
if ($event->pdfPath) {
|
||||
return new FilePdfWrapper($event->pdfPath, $event->fileName);
|
||||
}
|
||||
|
||||
// If it's content (base64, binary, etc.), create a wrapper
|
||||
if ($event->pdfContent) {
|
||||
return new ContentPdfWrapper($event->pdfContent, $event->fileName);
|
||||
}
|
||||
|
||||
// This shouldn't happen, but fallback just in case
|
||||
return DomPDF::loadView('pdf.invoice', ['invoice' => $event->invoice]);
|
||||
}
|
||||
}
|
||||
|
||||
77
app/Classes/Pdf/ContentPdfWrapper.php
Normal file
77
app/Classes/Pdf/ContentPdfWrapper.php
Normal file
@@ -0,0 +1,77 @@
|
||||
<?php
|
||||
|
||||
namespace App\Classes\Pdf;
|
||||
|
||||
class ContentPdfWrapper
|
||||
{
|
||||
private $content;
|
||||
|
||||
private $fileName;
|
||||
|
||||
private $tempPath;
|
||||
|
||||
public function __construct(string $content, ?string $fileName = null)
|
||||
{
|
||||
// Decode base64 if it looks like base64
|
||||
if ($this->isBase64($content)) {
|
||||
$content = base64_decode($content);
|
||||
}
|
||||
|
||||
$this->content = $content;
|
||||
$this->fileName = $fileName ?? 'invoice.pdf';
|
||||
|
||||
// Create temporary file for operations that need a file path
|
||||
$this->tempPath = tempnam(sys_get_temp_dir(), 'pdf_wrapper_');
|
||||
file_put_contents($this->tempPath, $content);
|
||||
}
|
||||
|
||||
public function save($path)
|
||||
{
|
||||
return file_put_contents($path, $this->content) !== false;
|
||||
}
|
||||
|
||||
public function download($name = null)
|
||||
{
|
||||
$name = $name ?: $this->fileName;
|
||||
|
||||
return response($this->content)
|
||||
->header('Content-Type', 'application/pdf')
|
||||
->header('Content-Disposition', 'attachment; filename="' . $name . '"');
|
||||
}
|
||||
|
||||
public function stream($name = null)
|
||||
{
|
||||
$name = $name ?: $this->fileName;
|
||||
|
||||
return response($this->content)
|
||||
->header('Content-Type', 'application/pdf')
|
||||
->header('Content-Disposition', 'inline; filename="' . $name . '"');
|
||||
}
|
||||
|
||||
public function output()
|
||||
{
|
||||
return $this->content;
|
||||
}
|
||||
|
||||
public function getContent(): string
|
||||
{
|
||||
return $this->content;
|
||||
}
|
||||
|
||||
public function getTempPath(): string
|
||||
{
|
||||
return $this->tempPath;
|
||||
}
|
||||
|
||||
private function isBase64(string $data): bool
|
||||
{
|
||||
return base64_encode(base64_decode($data, true)) === $data;
|
||||
}
|
||||
|
||||
public function __destruct()
|
||||
{
|
||||
if (file_exists($this->tempPath)) {
|
||||
unlink($this->tempPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
48
app/Classes/Pdf/FilePdfWrapper.php
Normal file
48
app/Classes/Pdf/FilePdfWrapper.php
Normal file
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
namespace App\Classes\Pdf;
|
||||
|
||||
class FilePdfWrapper
|
||||
{
|
||||
private $filePath;
|
||||
|
||||
private $fileName;
|
||||
|
||||
public function __construct(string $filePath, ?string $fileName = null)
|
||||
{
|
||||
$this->filePath = $filePath;
|
||||
$this->fileName = $fileName ?? basename($filePath);
|
||||
}
|
||||
|
||||
public function save($path)
|
||||
{
|
||||
return copy($this->filePath, $path);
|
||||
}
|
||||
|
||||
public function download($name = null)
|
||||
{
|
||||
$name = $name ?: $this->fileName;
|
||||
|
||||
return response()->download($this->filePath, $name);
|
||||
}
|
||||
|
||||
public function stream($name = null)
|
||||
{
|
||||
$name = $name ?: $this->fileName;
|
||||
|
||||
return response()->file($this->filePath, [
|
||||
'Content-Type' => 'application/pdf',
|
||||
'Content-Disposition' => 'inline; filename="' . $name . '"',
|
||||
]);
|
||||
}
|
||||
|
||||
public function output()
|
||||
{
|
||||
return file_get_contents($this->filePath);
|
||||
}
|
||||
|
||||
public function getFilePath(): string
|
||||
{
|
||||
return $this->filePath;
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
namespace App\Classes;
|
||||
|
||||
use App\Models\TaxRate;
|
||||
|
||||
/**
|
||||
* Class Price
|
||||
*/
|
||||
@@ -36,7 +38,12 @@ class Price
|
||||
$this->discount = $discount;
|
||||
}
|
||||
|
||||
public function __construct($priceAndCurrency = null, $free = false, $dontShowUnavailablePrice = false, $apply_exclusive_tax = false)
|
||||
public function hasDiscount(): bool
|
||||
{
|
||||
return $this->discount > 0;
|
||||
}
|
||||
|
||||
public function __construct($priceAndCurrency = null, $free = false, $dontShowUnavailablePrice = false, $apply_exclusive_tax = false, TaxRate|int|null $tax = null)
|
||||
{
|
||||
if (is_array($priceAndCurrency)) {
|
||||
$priceAndCurrency = (object) $priceAndCurrency;
|
||||
@@ -51,6 +58,8 @@ class Price
|
||||
'setup_fee' => $this->format($this->setup_fee),
|
||||
'tax' => $this->format($this->tax),
|
||||
'setup_fee_tax' => $this->format($this->setup_fee_tax),
|
||||
'total' => $this->format($this->total),
|
||||
'total_tax' => $this->format($this->total_tax),
|
||||
];
|
||||
|
||||
return;
|
||||
@@ -62,16 +71,17 @@ class Price
|
||||
$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 (config('settings.tax_enabled', false)) {
|
||||
$tax ??= Settings::tax();
|
||||
if ($tax) {
|
||||
// Inclusive has the tax included in the price
|
||||
if (config('settings.tax_type') == 'inclusive' || !$apply_exclusive_tax) {
|
||||
if (config('settings.tax_type', 'inclusive') == '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, '.', '');
|
||||
@@ -91,11 +101,14 @@ class Price
|
||||
}
|
||||
$this->has_setup_fee = isset($this->setup_fee) ? $this->setup_fee > 0 : false;
|
||||
$this->dontShowUnavailablePrice = $dontShowUnavailablePrice;
|
||||
|
||||
$this->formatted = (object) [
|
||||
'total' => $this->format($this->total),
|
||||
'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),
|
||||
'total_tax' => $this->format($this->total_tax),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -113,6 +126,7 @@ class Price
|
||||
}
|
||||
// Get the format
|
||||
$format = $this->currency->format;
|
||||
$price = $price ?? 0;
|
||||
switch ($format) {
|
||||
case '1.000,00':
|
||||
$price = number_format($price, 2, ',', '.');
|
||||
@@ -133,15 +147,18 @@ class Price
|
||||
|
||||
public function __toString()
|
||||
{
|
||||
return $this->formatted->price;
|
||||
return $this->formatted->total;
|
||||
}
|
||||
|
||||
public function __get($name)
|
||||
{
|
||||
if ($name == 'available') {
|
||||
return $this->currency || $this->is_free ? true : false;
|
||||
} else {
|
||||
return $this->$name;
|
||||
}
|
||||
return match ($name) {
|
||||
'total' => number_format($this->price + ($this->setup_fee ?? 0), 2, '.', ''),
|
||||
'total_tax' => number_format($this->tax + $this->setup_fee_tax, 2, '.', ''),
|
||||
// Subtotal is price + setup_fee - tax - setup_fee_tax
|
||||
'subtotal' => number_format(($this->price + ($this->setup_fee ?? 0)) - ($this->tax + $this->setup_fee_tax), 2, '.', ''),
|
||||
'available' => $this->currency || $this->is_free ? true : false,
|
||||
default => $this->$name ?? null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,12 +5,14 @@ namespace App\Classes;
|
||||
use App\Models\Currency;
|
||||
use App\Models\Setting;
|
||||
use App\Models\TaxRate;
|
||||
use App\Models\User;
|
||||
use App\Rules\Cidr;
|
||||
use DateTimeZone;
|
||||
use Exception;
|
||||
use Filament\Schemas\Components\Utilities\Get;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\HtmlString;
|
||||
use Minishlink\WebPush\VAPID;
|
||||
use Ramsey\Uuid\Uuid;
|
||||
|
||||
class Settings
|
||||
@@ -68,11 +70,37 @@ class Settings
|
||||
],
|
||||
[
|
||||
'name' => 'logo',
|
||||
'label' => 'Logo',
|
||||
'label' => 'Logo (Light Mode)',
|
||||
'type' => 'file',
|
||||
'required' => false,
|
||||
'accept' => ['image/*'],
|
||||
'file_name' => 'logo.webp',
|
||||
'file_name' => 'logo-light.webp',
|
||||
'description' => 'Upload a logo to be displayed on light backgrounds.',
|
||||
],
|
||||
[
|
||||
'name' => 'logo_dark',
|
||||
'label' => 'Logo (Dark Mode)',
|
||||
'type' => 'file',
|
||||
'required' => false,
|
||||
'accept' => ['image/*'],
|
||||
'file_name' => 'logo-dark.webp',
|
||||
'description' => 'Upload a logo to be displayed on dark backgrounds.',
|
||||
],
|
||||
[
|
||||
'name' => 'favicon',
|
||||
'label' => 'Favicon',
|
||||
'type' => 'file',
|
||||
'required' => false,
|
||||
'accept' => ['image/x-icon', 'image/png', 'image/svg+xml'],
|
||||
'file_name' => 'favicon.ico',
|
||||
'description' => 'Upload a .ico, .png, or .svg file to be used as the browser icon.',
|
||||
],
|
||||
[
|
||||
'name' => 'system_email_address',
|
||||
'label' => 'System Email Address',
|
||||
'type' => 'email',
|
||||
'required' => true,
|
||||
'description' => 'The email address used for system emails, such as CronJob failures, updates, etc.',
|
||||
],
|
||||
[
|
||||
'name' => 'tos',
|
||||
@@ -214,6 +242,7 @@ class Settings
|
||||
'label' => 'Disable Mail',
|
||||
'type' => 'checkbox',
|
||||
'database_type' => 'boolean',
|
||||
'live' => true,
|
||||
'default' => true,
|
||||
],
|
||||
[
|
||||
@@ -227,28 +256,28 @@ class Settings
|
||||
'name' => 'mail_host',
|
||||
'label' => 'Mail Host',
|
||||
'type' => 'text',
|
||||
'required' => false,
|
||||
'required' => fn (Get $get) => !$get('mail_disable'),
|
||||
'override' => 'mail.mailers.smtp.host',
|
||||
],
|
||||
[
|
||||
'name' => 'mail_port',
|
||||
'label' => 'Mail Port',
|
||||
'type' => 'text',
|
||||
'required' => false,
|
||||
'required' => fn (Get $get) => !$get('mail_disable'),
|
||||
'override' => 'mail.mailers.smtp.port',
|
||||
],
|
||||
[
|
||||
'name' => 'mail_username',
|
||||
'label' => 'Mail Username',
|
||||
'type' => 'text',
|
||||
'required' => false,
|
||||
'required' => fn (Get $get) => !$get('mail_disable'),
|
||||
'override' => 'mail.mailers.smtp.username',
|
||||
],
|
||||
[
|
||||
'name' => 'mail_password',
|
||||
'label' => 'Mail Password',
|
||||
'type' => 'password',
|
||||
'required' => false,
|
||||
'required' => fn (Get $get) => !$get('mail_disable'),
|
||||
'encrypted' => true,
|
||||
'override' => 'mail.mailers.smtp.password',
|
||||
],
|
||||
@@ -269,14 +298,14 @@ class Settings
|
||||
'name' => 'mail_from_address',
|
||||
'label' => 'Mail From Address',
|
||||
'type' => 'email',
|
||||
'required' => false,
|
||||
'required' => fn (Get $get) => !$get('mail_disable'),
|
||||
'override' => 'mail.from.address',
|
||||
],
|
||||
[
|
||||
'name' => 'mail_from_name',
|
||||
'label' => 'Mail From Name',
|
||||
'type' => 'text',
|
||||
'required' => false,
|
||||
'required' => fn (Get $get) => !$get('mail_disable'),
|
||||
'override' => 'mail.from.name',
|
||||
],
|
||||
|
||||
@@ -285,7 +314,7 @@ class Settings
|
||||
'name' => 'mail_header',
|
||||
'label' => 'Header',
|
||||
'type' => 'markdown',
|
||||
'required' => false,
|
||||
'required' => fn (Get $get) => !$get('mail_disable'),
|
||||
'default' => '',
|
||||
'disable_toolbar' => true,
|
||||
],
|
||||
@@ -293,7 +322,7 @@ class Settings
|
||||
'name' => 'mail_footer',
|
||||
'label' => 'Footer',
|
||||
'type' => 'markdown',
|
||||
'required' => false,
|
||||
'required' => fn (Get $get) => !$get('mail_disable'),
|
||||
'default' => '',
|
||||
'disable_toolbar' => true,
|
||||
],
|
||||
@@ -301,7 +330,7 @@ class Settings
|
||||
'name' => 'mail_css',
|
||||
'label' => 'Mail CSS',
|
||||
'type' => 'markdown',
|
||||
'required' => false,
|
||||
'required' => fn (Get $get) => !$get('mail_disable'),
|
||||
'default' => '',
|
||||
'disable_toolbar' => true,
|
||||
],
|
||||
@@ -315,6 +344,13 @@ class Settings
|
||||
'required' => true,
|
||||
'database_type' => 'array',
|
||||
],
|
||||
[
|
||||
'name' => 'ticket_client_closing_disabled',
|
||||
'label' => 'Disallow clients from closing tickets',
|
||||
'type' => 'checkbox',
|
||||
'database_type' => 'boolean',
|
||||
'default' => false,
|
||||
],
|
||||
// Email piping
|
||||
[
|
||||
'name' => 'ticket_mail_piping',
|
||||
@@ -503,6 +539,22 @@ class Settings
|
||||
'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}/',
|
||||
],
|
||||
[
|
||||
'name' => 'invoice_proforma',
|
||||
'label' => 'Proforma Invoices',
|
||||
'type' => 'checkbox',
|
||||
'database_type' => 'boolean',
|
||||
'default' => false,
|
||||
'description' => 'Proforma invoices will not be assigned an official invoice number until payment is received and will be marked as "Proforma".',
|
||||
],
|
||||
[
|
||||
'name' => 'invoice_snapshot',
|
||||
'label' => 'Invoice Snapshot',
|
||||
'type' => 'checkbox',
|
||||
'database_type' => 'boolean',
|
||||
'default' => true,
|
||||
'description' => 'Save a snapshot of important data (name, address, etc.) on the invoice when it is paid. This ensures that if someone changes their details later, old invoices will still have the correct information.',
|
||||
],
|
||||
],
|
||||
'other' => [
|
||||
[
|
||||
@@ -571,24 +623,24 @@ class Settings
|
||||
return $settings;
|
||||
}
|
||||
|
||||
public static function tax()
|
||||
public static function tax(?User $user = null)
|
||||
{
|
||||
// Use once so the query is only run once
|
||||
return once(function () {
|
||||
$country = Auth::user()?->properties()->where('key', 'country')->value('value') ?? null;
|
||||
return once(function () use ($user) {
|
||||
$user ??= Auth::user();
|
||||
// Get country from user properties
|
||||
$country = $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;
|
||||
}
|
||||
$taxRate = TaxRate::whereIn('country', [$country, 'all'])
|
||||
->orderByRaw('country = ? desc', [$country])
|
||||
->first();
|
||||
|
||||
return 0;
|
||||
return $taxRate ?: 0;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -631,4 +683,28 @@ class Settings
|
||||
|
||||
return compact('uuid', 'hour', 'minute');
|
||||
}
|
||||
|
||||
public static function validateOrCreateVapidKeys(): bool
|
||||
{
|
||||
$publicKey = config('settings.vapid_public_key');
|
||||
$privateKey = config('settings.vapid_private_key');
|
||||
if ($publicKey && $privateKey && strlen($publicKey) > 80 && strlen($privateKey) > 40) {
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
$vapid = VAPID::createVapidKeys();
|
||||
Setting::updateOrCreate(
|
||||
['key' => 'vapid_public_key', 'encrypted' => true],
|
||||
['value' => $vapid['publicKey']]
|
||||
);
|
||||
Setting::updateOrCreate(
|
||||
['key' => 'vapid_private_key', 'encrypted' => true],
|
||||
['value' => $vapid['privateKey']]
|
||||
);
|
||||
|
||||
return true;
|
||||
} catch (Exception $e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Helpers\NotificationHelper;
|
||||
use App\Models\Setting;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
@@ -57,10 +58,25 @@ class CheckForUpdates extends Command
|
||||
$this->info('You are using the latest version: ' . config('app.version'));
|
||||
}
|
||||
}
|
||||
Setting::updateOrCreate(
|
||||
['key' => 'latest_version'],
|
||||
['value' => $version['latest']]
|
||||
);
|
||||
// Check if we have a new latest stable version
|
||||
$setting = Setting::where('key', 'latest_version')->first();
|
||||
if ($setting && $setting->value != $version['latest']) {
|
||||
// Send notification to all admins
|
||||
$this->info('New stable version detected, sending notification to system email address.');
|
||||
$currentVersion = config('app.version');
|
||||
|
||||
// Send notification to all admins
|
||||
NotificationHelper::sendSystemEmailNotification(
|
||||
'New stable version available',
|
||||
<<<HTML
|
||||
A new stable version of Paymenter is available: {$version['latest']}.<br>
|
||||
You are currently using version: {$currentVersion}.<br>
|
||||
|
||||
Please update as soon as possible.
|
||||
HTML
|
||||
);
|
||||
}
|
||||
$setting->update(['value' => $version['latest']]);
|
||||
|
||||
$this->info('Update check completed.');
|
||||
}
|
||||
|
||||
@@ -3,12 +3,15 @@
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Helpers\ExtensionHelper;
|
||||
use App\Helpers\NotificationHelper;
|
||||
use App\Jobs\Server\SuspendJob;
|
||||
use App\Jobs\Server\TerminateJob;
|
||||
use App\Models\EmailLog;
|
||||
use App\Models\CronStat;
|
||||
use App\Models\Invoice;
|
||||
use App\Models\Notification;
|
||||
use App\Models\Service;
|
||||
use App\Models\ServiceUpgrade;
|
||||
use App\Models\Setting;
|
||||
use App\Models\Ticket;
|
||||
use Exception;
|
||||
use Illuminate\Console\Command;
|
||||
@@ -31,6 +34,8 @@ class CronJob extends Command
|
||||
*/
|
||||
protected $description = 'Run automated tasks';
|
||||
|
||||
private int $successFullCharges = 0;
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*/
|
||||
@@ -38,136 +43,201 @@ class CronJob extends Command
|
||||
{
|
||||
Config::set('audit.console', true);
|
||||
|
||||
// Send invoices if due date is x days away
|
||||
$sendedInvoices = 0;
|
||||
Service::where('status', 'active')->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();
|
||||
|
||||
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();
|
||||
try {
|
||||
// Send invoices if due date is x days away
|
||||
$this->runCronJob('invoices_created', function ($number = 0) {
|
||||
Service::where('status', 'active')->where('expires_at', '<', now()->addDays((int) config('settings.cronjob_invoice', 7)))->get()->each(function ($service) use (&$number) {
|
||||
// Does the service have already a pending invoice?
|
||||
if ($service->invoices()->where('status', 'pending')->exists() || $service->cancellation()->exists()) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Create invoice
|
||||
$invoice = $service->invoices()->make([
|
||||
'user_id' => $service->user_id,
|
||||
'status' => 'pending',
|
||||
'due_at' => $service->expires_at,
|
||||
'currency_code' => $service->currency_code,
|
||||
]);
|
||||
// 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
|
||||
$service->price = $service->calculatePrice();
|
||||
$service->save();
|
||||
}
|
||||
}
|
||||
|
||||
$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,
|
||||
]);
|
||||
// If service price is 0, immediately activate next period
|
||||
if ($service->price <= 0) {
|
||||
(new \App\Services\Service\RenewServiceService)->handle($service);
|
||||
$number++;
|
||||
|
||||
$this->payInvoiceWithCredits($invoice->refresh());
|
||||
} catch (Exception $e) {
|
||||
DB::rollBack();
|
||||
$this->error('Error creating invoice for service ' . $service->id . ': ' . $e->getMessage());
|
||||
return;
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
// Create invoice
|
||||
$invoice = $service->invoices()->make([
|
||||
'user_id' => $service->user_id,
|
||||
'status' => 'pending',
|
||||
'due_at' => $service->expires_at,
|
||||
'currency_code' => $service->currency_code,
|
||||
]);
|
||||
|
||||
DB::commit();
|
||||
$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,
|
||||
]);
|
||||
|
||||
$sendedInvoices++;
|
||||
});
|
||||
$this->info('Sending invoices if due date is ' . config('settings.cronjob_invoice', 7) . ' days away: ' . $sendedInvoices . ' invoices');
|
||||
$invoice = $invoice->refresh();
|
||||
|
||||
// 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']);
|
||||
$this->payInvoiceWithCredits($invoice);
|
||||
|
||||
$service->update(['status' => 'cancelled']);
|
||||
// Charge billing agreements
|
||||
if ($service->billing_agreement_id && $invoice->fresh()->status === 'pending') {
|
||||
DB::afterCommit(function () use ($invoice, $service) {
|
||||
try {
|
||||
ExtensionHelper::charge(
|
||||
$service->billingAgreement->gateway,
|
||||
$invoice,
|
||||
$service->billingAgreement
|
||||
);
|
||||
|
||||
if ($service->product->stock) {
|
||||
$service->product->increment('stock', $service->quantity);
|
||||
}
|
||||
$this->successFullCharges++;
|
||||
} catch (Exception $e) {
|
||||
// Ignore errors here
|
||||
NotificationHelper::invoicePaymentFailedNotification($invoice->user, $invoice);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
$ordersCancelled++;
|
||||
});
|
||||
$this->info('Cancelling services if first invoice is not paid after ' . config('settings.cronjob_order_cancel', 7) . ' days: ' . $ordersCancelled . ' orders');
|
||||
$number++;
|
||||
});
|
||||
|
||||
$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']);
|
||||
return $number;
|
||||
});
|
||||
|
||||
$updatedUpgradeInvoices++;
|
||||
$this->runCronJob('orders_cancelled', function ($number = 0) {
|
||||
// Cancel services if first invoice is not paid after x days
|
||||
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 (&$number) {
|
||||
$service->invoices()->where('status', 'pending')->update(['status' => 'cancelled']);
|
||||
|
||||
return;
|
||||
}
|
||||
$service->update(['status' => 'cancelled']);
|
||||
|
||||
$upgrade->invoice->items()->update([
|
||||
'price' => $upgrade->calculatePrice()->price,
|
||||
]);
|
||||
if ($service->product->stock !== null) {
|
||||
$service->product->increment('stock', $service->quantity);
|
||||
}
|
||||
|
||||
$updatedUpgradeInvoices++;
|
||||
});
|
||||
$number++;
|
||||
});
|
||||
|
||||
// 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);
|
||||
return $number;
|
||||
});
|
||||
|
||||
$service->update(['status' => 'suspended']);
|
||||
$ordersSuspended++;
|
||||
});
|
||||
$this->info('Suspending orders if due date is overdue for ' . config('settings.cronjob_order_suspend', 2) . ' days: ' . $ordersSuspended . ' orders');
|
||||
$this->runCronJob('upgrade_invoices_updated', function ($number = 0) {
|
||||
// Update pending upgrade invoices
|
||||
ServiceUpgrade::where('status', 'pending')->get()->each(function ($upgrade) use (&$number) {
|
||||
if ($upgrade->service->expires_at < now()) {
|
||||
$upgrade->update(['status' => 'cancelled']);
|
||||
$upgrade->invoice->update(['status' => 'cancelled']);
|
||||
|
||||
// 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']);
|
||||
$number++;
|
||||
|
||||
if ($service->product->stock) {
|
||||
$service->product->increment('stock', $service->quantity);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
$ordersTerminated++;
|
||||
});
|
||||
$this->info('Terminating orders if due date is overdue for ' . config('settings.cronjob_order_terminate', 14) . ' days: ' . $ordersTerminated . ' orders');
|
||||
$upgrade->invoice->items()->update([
|
||||
'price' => $upgrade->calculatePrice()->price,
|
||||
]);
|
||||
|
||||
// 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');
|
||||
$number++;
|
||||
});
|
||||
|
||||
// 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();
|
||||
return $number;
|
||||
});
|
||||
|
||||
$this->runCronJob('services_suspended', function ($number = 0) {
|
||||
// Suspend orders if due date is overdue for x days
|
||||
Service::where('status', 'active')->where('expires_at', '<', now()->subDays((int) config('settings.cronjob_order_suspend', 2)))->get()->each(function ($service) use (&$number) {
|
||||
SuspendJob::dispatch($service);
|
||||
|
||||
$service->update(['status' => 'suspended']);
|
||||
$number++;
|
||||
});
|
||||
|
||||
return $number;
|
||||
});
|
||||
|
||||
$this->runCronJob('services_terminated', function ($number = 0) {
|
||||
// Terminate orders if due date is overdue for x days
|
||||
Service::where('status', 'suspended')->where('expires_at', '<', now()->subDays((int) config('settings.cronjob_order_terminate', 14)))->each(function ($service) use (&$number) {
|
||||
TerminateJob::dispatch($service);
|
||||
|
||||
$service->update(['status' => 'cancelled']);
|
||||
// Cancel outstanding invoices
|
||||
$service->invoices()->where('status', 'pending')->update(['status' => 'cancelled']);
|
||||
|
||||
if ($service->product->stock !== null) {
|
||||
$service->product->increment('stock', $service->quantity);
|
||||
}
|
||||
|
||||
$number++;
|
||||
});
|
||||
|
||||
return $number;
|
||||
});
|
||||
|
||||
$this->runCronJob('tickets_closed', function ($number = 0) {
|
||||
// Close tickets if no response for x days
|
||||
Ticket::where('status', 'replied')->each(function ($ticket) use (&$number) {
|
||||
$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']);
|
||||
$number++;
|
||||
}
|
||||
});
|
||||
|
||||
return $number;
|
||||
});
|
||||
|
||||
$this->runCronJob('email_logs_deleted', function ($number = 0) {
|
||||
$number = Notification::where('created_at', '<', now()->subDays((int) config('settings.cronjob_delete_email_logs', 90)))->count();
|
||||
// Delete email logs older then x
|
||||
Notification::where('created_at', '<', now()->subDays((int) config('settings.cronjob_delete_email_logs', 90)))->delete();
|
||||
|
||||
return $number;
|
||||
});
|
||||
|
||||
} catch (Exception $e) {
|
||||
DB::rollBack();
|
||||
|
||||
NotificationHelper::sendSystemEmailNotification('Cron Job Error', <<<HTML
|
||||
An error occurred while running the cron job:<br>
|
||||
<pre>{$e->getMessage()}.</pre><br>
|
||||
Please check the system and application logs for more details.
|
||||
HTML);
|
||||
|
||||
throw $e;
|
||||
}
|
||||
|
||||
DB::commit();
|
||||
|
||||
Setting::updateOrCreate(
|
||||
['key' => 'last_cron_run', 'settingable_type' => CronStat::class],
|
||||
['value' => now()->toDateTimeString(), 'type' => 'string']
|
||||
);
|
||||
|
||||
CronStat::create([
|
||||
'key' => 'invoice_charged',
|
||||
'value' => $this->successFullCharges,
|
||||
'date' => now()->toDateString(),
|
||||
]);
|
||||
|
||||
$this->info('Successfully charged ' . $this->successFullCharges . ' invoices.');
|
||||
|
||||
// Check for updates
|
||||
$this->info('Checking for updates...');
|
||||
@@ -186,7 +256,23 @@ class CronJob extends Command
|
||||
$credits->amount -= $invoice->remaining;
|
||||
$credits->save();
|
||||
|
||||
ExtensionHelper::addPayment($invoice->id, null, amount: $invoice->remaining);
|
||||
ExtensionHelper::addPayment($invoice->id, null, amount: $invoice->remaining, isCreditTransaction: true);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Function to run a specific cron job by its key.
|
||||
*/
|
||||
private function runCronJob(string $key, callable $callback): void
|
||||
{
|
||||
$items = $callback() ?? 0;
|
||||
|
||||
CronStat::create([
|
||||
'key' => $key,
|
||||
'value' => $items,
|
||||
'date' => now()->toDateString(),
|
||||
]);
|
||||
|
||||
$this->info("Cronjob task '" . __('admin.cronjob.' . $key) . "' completed: Processed " . $items . ' items.');
|
||||
}
|
||||
}
|
||||
|
||||
45
app/Console/Commands/Extension/Install.php
Normal file
45
app/Console/Commands/Extension/Install.php
Normal file
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands\Extension;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class Install extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'app:extension:install {type} {name}';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'Internal command used to call upgrade on an extension';
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*/
|
||||
public function handle()
|
||||
{
|
||||
$extensionClass = 'Paymenter\\Extensions\\' . ucfirst($this->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, 'installed')) {
|
||||
try {
|
||||
$extensionInstance->installed();
|
||||
} catch (\Exception $e) {
|
||||
Log::error("Error during installation of extension {$this->argument('name')}: " . $e->getMessage());
|
||||
|
||||
return $this->error('An error occurred while installing the extension: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -34,7 +34,7 @@ class Upgrade extends Command
|
||||
$extensionInstance = new $extensionClass;
|
||||
if (method_exists($extensionInstance, 'upgraded')) {
|
||||
try {
|
||||
$extensionInstance->upgraded();
|
||||
$extensionInstance->upgraded($this->argument('oldVersion'));
|
||||
} catch (\Exception $e) {
|
||||
Log::error("Error during upgrade of extension {$this->argument('name')}: " . $e->getMessage());
|
||||
|
||||
|
||||
@@ -38,90 +38,99 @@ class FetchEmails extends Command
|
||||
}
|
||||
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 (<ticket message id>@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',
|
||||
try {
|
||||
$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'),
|
||||
]);
|
||||
|
||||
// // Add reply to ticket
|
||||
$message = $ticket->messages()->create([
|
||||
'message' => $body,
|
||||
'user_id' => $ticket->user_id,
|
||||
'ticket_mail_log_id' => $ticketMailLog->id,
|
||||
]);
|
||||
// Fetch emails from the mailbox
|
||||
$emails = $mailbox->inbox();
|
||||
|
||||
// Foreach attachment
|
||||
foreach ($email->attachments() as $attachment) {
|
||||
$extension = pathinfo($attachment->filename(), PATHINFO_EXTENSION);
|
||||
// Randomize filename
|
||||
$newName = Str::ulid() . '.' . $extension;
|
||||
$path = 'tickets/uploads/' . $newName;
|
||||
foreach ($emails->messages()->since(now()->subDays(1))->withHeaders()->withBody()->get() as $email) {
|
||||
if (TicketMailLog::where('message_id', $email->messageId())->exists()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$attachment->save(storage_path('app/' . $path));
|
||||
$body = \EmailReplyParser\EmailReplyParser::parseReply($email->text());
|
||||
|
||||
$message->attachments()->create([
|
||||
'path' => $path,
|
||||
'filename' => $attachment->filename(),
|
||||
'mime_type' => File::mimeType(storage_path('app/' . $path)),
|
||||
'filesize' => File::size(storage_path('app/' . $path)),
|
||||
// Check headers to see if this email is a reply
|
||||
$replyTo = $email->inReplyTo();
|
||||
if (!$replyTo || count($replyTo) === 0) {
|
||||
// Create email log but don't process
|
||||
$this->failedEmailLog($email);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// Validate if in reply to another ticket (<ticket message id>@hostname)
|
||||
|
||||
if (!preg_match('/^(\d+)@/', $replyTo[0], $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)),
|
||||
]);
|
||||
}
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
\Log::error('FetchEmails failed: ' . $e->getMessage());
|
||||
} finally {
|
||||
// Ensure the mailbox is disconnected
|
||||
if (isset($mailbox)) {
|
||||
$mailbox->disconnect();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
960
app/Console/Commands/ImportFromWhmcs.php
Normal file
960
app/Console/Commands/ImportFromWhmcs.php
Normal file
@@ -0,0 +1,960 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Category;
|
||||
use App\Models\ConfigOption;
|
||||
use App\Models\CustomProperty;
|
||||
use App\Models\Product;
|
||||
use App\Models\Service;
|
||||
use App\Models\Setting;
|
||||
use App\Models\User;
|
||||
use App\Providers\SettingsProvider;
|
||||
use Closure;
|
||||
use DB;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Http\Client\Batch;
|
||||
use Log;
|
||||
use PDO;
|
||||
use PDOException;
|
||||
use PDOStatement;
|
||||
use Throwable;
|
||||
|
||||
use function Laravel\Prompts\password;
|
||||
use function Laravel\Prompts\text;
|
||||
|
||||
class ImportFromWhmcs extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'app:import-from-whmcs {dbname} {username?} {host?} {port?}';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'Command description';
|
||||
|
||||
/**
|
||||
* The PDO connection to WHMCS database
|
||||
*
|
||||
* @var PDO
|
||||
*/
|
||||
protected $pdo;
|
||||
|
||||
/**
|
||||
* @var int
|
||||
*/
|
||||
protected $batchSize = 500;
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*/
|
||||
public function handle()
|
||||
{
|
||||
// Set max memory to 1GB
|
||||
ini_set('memory_limit', '1024M');
|
||||
|
||||
$dbname = $this->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 WHMCS database, Starting migration...');
|
||||
|
||||
$this->prepareDatabase();
|
||||
|
||||
DB::statement('SET foreign_key_checks=0');
|
||||
|
||||
// Remove default currencies
|
||||
DB::table('currencies')->truncate();
|
||||
// Import currencies
|
||||
$this->importCurrencies();
|
||||
$this->importUsers();
|
||||
$this->importAdmins();
|
||||
$this->importCategories();
|
||||
$this->importConfigOptions();
|
||||
$this->importProducts();
|
||||
$this->importTickets();
|
||||
$this->importOrders();
|
||||
$this->importServices();
|
||||
$this->importCancellations();
|
||||
$this->importInvoices();
|
||||
$this->importInvoiceItems();
|
||||
$this->importPayments();
|
||||
|
||||
DB::statement('SET foreign_key_checks=1');
|
||||
|
||||
SettingsProvider::flushCache();
|
||||
} catch (PDOException $e) {
|
||||
$this->fail('Connection failed: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private function prepareDatabase()
|
||||
{
|
||||
// Rerun migrations
|
||||
$this->call('migrate:fresh', ['--force' => true]);
|
||||
// Seed default data
|
||||
$this->call('db:seed', ['--force' => true]);
|
||||
$this->call('db:seed', ['--class' => 'CustomPropertySeeder', '--force' => true]);
|
||||
}
|
||||
|
||||
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.');
|
||||
}
|
||||
|
||||
private function count(string $table): int
|
||||
{
|
||||
$stmt = $this->pdo->prepare('SELECT COUNT(*) as count FROM ' . $table);
|
||||
$stmt->execute();
|
||||
$result = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
return (int) ($result['count'] ?? 0);
|
||||
}
|
||||
|
||||
private function importCurrencies()
|
||||
{
|
||||
$this->info('Importing currencies... (' . $this->count('tblcurrencies') . ' records)');
|
||||
|
||||
$this->migrateInBatch('tblcurrencies', 'SELECT * FROM tblcurrencies LIMIT :limit OFFSET :offset', function ($records) {
|
||||
$data = [];
|
||||
foreach ($records as $record) {
|
||||
$format = match ($record['format']) {
|
||||
1 => '1 000.00', // 1234.56
|
||||
2 => '1,000.00', // 1,234.56
|
||||
4 => '1 000,00', // 1,234
|
||||
default => '1.000,00', // 1.234,56
|
||||
};
|
||||
$data[] = [
|
||||
'name' => $record['code'],
|
||||
'code' => $record['code'],
|
||||
'prefix' => $record['prefix'],
|
||||
'suffix' => $record['suffix'],
|
||||
'format' => $format,
|
||||
];
|
||||
if ($record['default'] == 1) {
|
||||
// Set default currency
|
||||
Setting::updateOrCreate(
|
||||
['key' => 'default_currency'],
|
||||
['value' => $record['code']]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
DB::table('currencies')->insert($data);
|
||||
});
|
||||
}
|
||||
|
||||
private function importUsers()
|
||||
{
|
||||
$this->info('Importing users... (' . $this->count('tblclients') . ' records)');
|
||||
$customProperties = CustomProperty::where('model', User::class)->get()->keyBy('key');
|
||||
|
||||
$this->migrateInBatch('tblclients', 'SELECT * FROM tblclients LIMIT :limit OFFSET :offset', function ($records) use ($customProperties) {
|
||||
$data = [];
|
||||
$properties = [];
|
||||
$credits = [];
|
||||
|
||||
foreach ($records as $record) {
|
||||
// Get client (join tblusers_clients on tblusers.id = tblusers_clients.auth_user_id and tblusers_clients.owner = 1)
|
||||
$stmt = $this->pdo->prepare('SELECT * FROM tblusers WHERE id = (SELECT auth_user_id FROM tblusers_clients WHERE client_id = :client_id AND owner = 1 LIMIT 1)');
|
||||
$stmt->bindValue(':client_id', $record['id'], PDO::PARAM_INT);
|
||||
$stmt->execute();
|
||||
$user = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
if (!$user) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$data[] = [
|
||||
'id' => $record['id'],
|
||||
'first_name' => $record['firstname'],
|
||||
'last_name' => $record['lastname'],
|
||||
'email' => $record['email'],
|
||||
'password' => $user['password'],
|
||||
'email_verified_at' => $user['email_verified_at'] ? $user['email_verified_at'] : null,
|
||||
'updated_at' => $record['updated_at'],
|
||||
'created_at' => $record['created_at'],
|
||||
];
|
||||
|
||||
// Custom properties
|
||||
foreach ($customProperties as $key => $property) {
|
||||
// address1 -> address, companyname -> company_name
|
||||
$whmcsKey = match ($key) {
|
||||
'address' => 'address1',
|
||||
'company_name' => 'companyname',
|
||||
'postcode' => 'zip',
|
||||
'phonenumber' => 'phone',
|
||||
default => $key,
|
||||
};
|
||||
if (isset($record[$key]) && $record[$key] !== '') {
|
||||
array_push($properties, [
|
||||
'key' => $key,
|
||||
'value' => $record[$whmcsKey],
|
||||
'model_id' => $record['id'],
|
||||
'model_type' => User::class,
|
||||
'name' => $property->name,
|
||||
'custom_property_id' => $property->id,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
// Credits
|
||||
if ($record['credit'] > 0) {
|
||||
$currency = $this->pdo->prepare('SELECT * FROM tblcurrencies WHERE id = :id LIMIT 1');
|
||||
$currency->bindValue(':id', $record['currency'], PDO::PARAM_INT);
|
||||
$currency->execute();
|
||||
$currency = $currency->fetch(PDO::FETCH_ASSOC);
|
||||
array_push($credits, [
|
||||
'user_id' => $record['id'],
|
||||
'amount' => $record['credit'],
|
||||
'currency_code' => $currency['code'] ?? null,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
DB::table('users')->insert($data);
|
||||
if (count($properties) > 0) {
|
||||
DB::table('properties')->insert($properties);
|
||||
}
|
||||
if (count($credits) > 0) {
|
||||
DB::table('credits')->insert($credits);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private function importAdmins()
|
||||
{
|
||||
$this->info('Importing admins... (' . $this->count('tbladmins') . ' records)');
|
||||
|
||||
$this->migrateInBatch('tbladmins', 'SELECT * FROM tbladmins LIMIT :limit OFFSET :offset', function ($records) {
|
||||
$data = [];
|
||||
foreach ($records as $record) {
|
||||
if ($record['roleid'] != 1 || $record['disabled'] == 1 || DB::table('users')->where('email', $record['email'])->exists()) {
|
||||
// Only import administrators
|
||||
continue;
|
||||
}
|
||||
$data[] = [
|
||||
'id' => $record['id'],
|
||||
'first_name' => $record['firstname'],
|
||||
'last_name' => $record['lastname'],
|
||||
'email' => $record['email'],
|
||||
'password' => $record['password'],
|
||||
'role_id' => 1, // Admin role
|
||||
'email_verified_at' => null,
|
||||
'created_at' => $record['created_at'],
|
||||
'updated_at' => $record['lastlogin'] ?? now(),
|
||||
];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private function importCategories()
|
||||
{
|
||||
$this->info('Importing categories... (' . $this->count('tblproductgroups') . ' records)');
|
||||
|
||||
$this->migrateInBatch('tblproductgroups', 'SELECT * FROM tblproductgroups LIMIT :limit OFFSET :offset', function ($records) {
|
||||
$data = [];
|
||||
foreach ($records as $record) {
|
||||
$data[] = [
|
||||
'id' => $record['id'],
|
||||
'name' => $record['name'],
|
||||
'description' => $record['tagline'],
|
||||
'sort' => $record['order'],
|
||||
'slug' => $record['slug'],
|
||||
'created_at' => $record['created_at'],
|
||||
'updated_at' => $record['updated_at'],
|
||||
];
|
||||
}
|
||||
|
||||
DB::table('categories')->insert($data);
|
||||
});
|
||||
}
|
||||
|
||||
private function importConfigOptions()
|
||||
{
|
||||
$this->info('Importing config options... (' . $this->count('tblproductconfigoptions') . ' records)');
|
||||
|
||||
$this->migrateInBatch('tblproductconfigoptions', 'SELECT * FROM tblproductconfigoptions LIMIT :limit OFFSET :offset', function ($records) {
|
||||
$data = [];
|
||||
$options = [];
|
||||
$planData = [];
|
||||
$priceData = [];
|
||||
|
||||
foreach ($records as $record) {
|
||||
if (strpos($record['optionname'], '|') !== false) {
|
||||
$environmentVariable = explode('|', $record['optionname'])[0];
|
||||
$name = explode('|', $record['optionname'])[1] ?? $record['optionname'];
|
||||
} else {
|
||||
$environmentVariable = null;
|
||||
$name = $record['optionname'];
|
||||
}
|
||||
$data[] = [
|
||||
'id' => $record['id'],
|
||||
'name' => $name,
|
||||
'env_variable' => $environmentVariable,
|
||||
'type' => match ($record['optiontype']) {
|
||||
1 => 'select',
|
||||
2 => 'radio',
|
||||
3 => 'checkbox',
|
||||
4 => 'number',
|
||||
default => 'select',
|
||||
},
|
||||
'sort' => $record['order'],
|
||||
'hidden' => $record['hidden'],
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
];
|
||||
|
||||
// Get options
|
||||
$stmt = $this->pdo->prepare('SELECT * FROM tblproductconfigoptionssub WHERE configid = :configid');
|
||||
$stmt->bindValue(':configid', $record['id'], PDO::PARAM_INT);
|
||||
$stmt->execute();
|
||||
$optionRecords = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
foreach ($optionRecords as $option) {
|
||||
if (strpos($option['optionname'], '|') !== false) {
|
||||
$environmentVariable = explode('|', $option['optionname'])[0];
|
||||
$name = explode('|', $option['optionname'])[1] ?? $option['optionname'];
|
||||
} else {
|
||||
$environmentVariable = null;
|
||||
$name = $option['optionname'];
|
||||
}
|
||||
$options[] = [
|
||||
'id' => $record['id'] . $option['id'],
|
||||
'parent_id' => $option['configid'],
|
||||
'name' => $name,
|
||||
'env_variable' => $environmentVariable,
|
||||
'sort' => $option['sortorder'],
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
];
|
||||
// Get pricing for the option
|
||||
$stmt2 = $this->pdo->prepare('SELECT * FROM tblpricing WHERE type = "configoptions" AND relid = :relid');
|
||||
$stmt2->bindValue(':relid', $option['id'], PDO::PARAM_INT);
|
||||
$stmt2->execute();
|
||||
$prices = $stmt2->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
$this->priceMagic($prices, $planData, $priceData, ['id' => $record['id'] . $option['id'], 'paytype' => 'recurring'], ConfigOption::class);
|
||||
}
|
||||
}
|
||||
|
||||
DB::table('config_options')->insert($data);
|
||||
if (count($options) > 0) {
|
||||
DB::table('config_options')->insert($options);
|
||||
}
|
||||
// Insert plans and then prices
|
||||
foreach ($planData as $planKey => $plan) {
|
||||
$planId = DB::table('plans')->insertGetId($plan);
|
||||
if (isset($priceData[$planKey])) {
|
||||
foreach ($priceData[$planKey] as &$price) {
|
||||
$price['plan_id'] = $planId;
|
||||
}
|
||||
DB::table('prices')->insert($priceData[$planKey]);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private function priceMagic(&$prices, &$planData, &$priceData, $record, $priceableType = Product::class)
|
||||
{
|
||||
foreach ($prices as $pricing) {
|
||||
if ($record['paytype'] === 'onetime') {
|
||||
// One-time payment product, create a one-time plan
|
||||
$setupFee = $pricing['msetupfee'] ?? 0;
|
||||
|
||||
// Create a unique key to link plan and price
|
||||
$planKey = $record['id'] . '_onetime';
|
||||
|
||||
$planData[$planKey] = [
|
||||
'priceable_id' => $record['id'],
|
||||
'priceable_type' => $priceableType,
|
||||
'name' => 'One-Time',
|
||||
'type' => 'one-time',
|
||||
'billing_period' => 0,
|
||||
'billing_unit' => null,
|
||||
];
|
||||
|
||||
$currency = $this->pdo->prepare('SELECT * FROM tblcurrencies WHERE id = :id LIMIT 1');
|
||||
$currency->bindValue(':id', $pricing['currency'], PDO::PARAM_INT);
|
||||
$currency->execute();
|
||||
$currency = $currency->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
$priceData[$planKey][] = [
|
||||
'currency_code' => $currency['code'],
|
||||
'price' => $pricing['monthly'],
|
||||
'setup_fee' => $setupFee,
|
||||
];
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (['monthly', 'quarterly', 'semiannually', 'annually', 'biennially', 'triennially'] as $period) {
|
||||
if ($pricing[$period] > 0) {
|
||||
$setupFee = match ($period) {
|
||||
'monthly' => $pricing['msetupfee'],
|
||||
'quarterly' => $pricing['qsetupfee'],
|
||||
'semiannually' => $pricing['ssetupfee'],
|
||||
'annually' => $pricing['asetupfee'],
|
||||
'biennially' => $pricing['bsetupfee'],
|
||||
'triennially' => $pricing['tsetupfee'],
|
||||
default => 0,
|
||||
};
|
||||
|
||||
// Create a unique key to link plan and price
|
||||
$planKey = $record['id'] . '_' . $period;
|
||||
|
||||
$planData[$planKey] = [
|
||||
'priceable_id' => $record['id'],
|
||||
'priceable_type' => $priceableType,
|
||||
'name' => ucfirst($period),
|
||||
'type' => 'recurring',
|
||||
'billing_period' => match ($period) {
|
||||
'monthly' => 1,
|
||||
'quarterly' => 3,
|
||||
'semiannually' => 6,
|
||||
'annually' => 1,
|
||||
'biennially' => 2,
|
||||
'triennially' => 3,
|
||||
default => 1,
|
||||
},
|
||||
'billing_unit' => match ($period) {
|
||||
'monthly', 'quarterly', 'semiannually' => 'month',
|
||||
'annually', 'biennially', 'triennially' => 'year',
|
||||
default => 'month',
|
||||
},
|
||||
];
|
||||
|
||||
$currency = $this->pdo->prepare('SELECT * FROM tblcurrencies WHERE id = :id LIMIT 1');
|
||||
$currency->bindValue(':id', $pricing['currency'], PDO::PARAM_INT);
|
||||
$currency->execute();
|
||||
$currency = $currency->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
$priceData[$planKey][] = [
|
||||
'currency_code' => $currency['code'],
|
||||
'price' => $pricing[$period],
|
||||
'setup_fee' => $setupFee,
|
||||
];
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private function importProducts()
|
||||
{
|
||||
$this->info('Importing products... (' . $this->count('tblproducts') . ' records)');
|
||||
|
||||
$this->migrateInBatch('tblproducts', 'SELECT * FROM tblproducts LIMIT :limit OFFSET :offset', function ($records) {
|
||||
$data = [];
|
||||
$planData = [];
|
||||
$priceData = [];
|
||||
$upgrades = [];
|
||||
|
||||
foreach ($records as $record) {
|
||||
$data[] = [
|
||||
'id' => $record['id'],
|
||||
'category_id' => $record['gid'],
|
||||
'name' => $record['name'],
|
||||
'description' => $record['description'],
|
||||
'slug' => !empty($record['slug']) ? $record['slug'] : \Str::slug($record['name']),
|
||||
'hidden' => $record['hidden'],
|
||||
'stock' => $record['stockcontrol'] ? $record['qty'] : null,
|
||||
'allow_quantity' => match ($record['allowqty']) {
|
||||
1 => 'separated',
|
||||
3 => 'combined',
|
||||
default => 'disabled',
|
||||
},
|
||||
'created_at' => $record['created_at'],
|
||||
'updated_at' => $record['updated_at'],
|
||||
];
|
||||
|
||||
// Upgrades
|
||||
$stmt = $this->pdo->prepare('SELECT * FROM tblproduct_upgrade_products WHERE product_id = :product_id');
|
||||
$stmt->bindValue(':product_id', $record['id'], PDO::PARAM_INT);
|
||||
$stmt->execute();
|
||||
$upgradeRecords = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
foreach ($upgradeRecords as $upgrade) {
|
||||
$upgrades[] = [
|
||||
'product_id' => $upgrade['product_id'],
|
||||
'upgrade_id' => $upgrade['upgrade_product_id'],
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
];
|
||||
}
|
||||
|
||||
// Config options
|
||||
$stmt = $this->pdo->prepare('SELECT * FROM tblproductconfiglinks WHERE pid = :pid');
|
||||
$stmt->bindValue(':pid', $record['id'], PDO::PARAM_INT);
|
||||
$stmt->execute();
|
||||
$configOptionRecords = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
foreach ($configOptionRecords as $configOptionGroupId) {
|
||||
// Get the config option group
|
||||
$configOptionGroup = $this->pdo->prepare('SELECT * FROM tblproductconfiggroups WHERE id = :id LIMIT 1');
|
||||
$configOptionGroup->bindValue(':id', $configOptionGroupId['gid'], PDO::PARAM_INT);
|
||||
$configOptionGroup->execute();
|
||||
$configOptionGroup = $configOptionGroup->fetch(PDO::FETCH_ASSOC);
|
||||
if (!$configOptionGroup) {
|
||||
continue;
|
||||
}
|
||||
// Get config options in the group
|
||||
$configOptions = $this->pdo->prepare('SELECT * FROM tblproductconfigoptions WHERE gid = :gid');
|
||||
$configOptions->bindValue(':gid', $configOptionGroup['id'], PDO::PARAM_INT);
|
||||
$configOptions->execute();
|
||||
$configOptions = $configOptions->fetchAll(PDO::FETCH_ASSOC);
|
||||
foreach ($configOptions as $configOption) {
|
||||
// Link config option to product
|
||||
DB::table('config_option_products')->insert([
|
||||
'product_id' => $record['id'],
|
||||
'config_option_id' => $configOption['id'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Insert products first
|
||||
DB::table('products')->insert($data);
|
||||
|
||||
// Insert upgrades
|
||||
if (count($upgrades) > 0) {
|
||||
DB::table('product_upgrades')->insert($upgrades);
|
||||
}
|
||||
|
||||
// Now process plans for all products in this batch
|
||||
foreach ($records as $record) {
|
||||
if ($record['paytype'] === 'free') {
|
||||
// Free product, create a free plan
|
||||
$planData[$record['id'] . '_free'] = [
|
||||
'priceable_id' => $record['id'],
|
||||
'priceable_type' => Product::class,
|
||||
'name' => 'Free',
|
||||
'type' => 'free',
|
||||
];
|
||||
|
||||
continue;
|
||||
}
|
||||
$stmt = $this->pdo->prepare('SELECT * FROM tblpricing WHERE type = "product" AND relid = :relid');
|
||||
$stmt->bindValue(':relid', $record['id'], PDO::PARAM_INT);
|
||||
$stmt->execute();
|
||||
$prices = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
$this->priceMagic($prices, $planData, $priceData, $record);
|
||||
}
|
||||
|
||||
// Insert plans and then prices
|
||||
foreach ($planData as $planKey => $plan) {
|
||||
$planId = DB::table('plans')->insertGetId($plan);
|
||||
|
||||
if (isset($priceData[$planKey])) {
|
||||
foreach ($priceData[$planKey] as &$price) {
|
||||
$price['plan_id'] = $planId;
|
||||
}
|
||||
DB::table('prices')->insert($priceData[$planKey]);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private function getUserIdTicket($message, &$userId)
|
||||
{
|
||||
if (!$userId) {
|
||||
if ($message['admin'] !== '') {
|
||||
// Admin is a first name + last name in WHMCS
|
||||
$user = DB::table('users')
|
||||
->where(DB::raw("CONCAT(first_name, ' ', last_name)"), $message['admin'])
|
||||
->orWhere('first_name', $message['admin'])
|
||||
->first();
|
||||
if ($user) {
|
||||
$userId = $user->id;
|
||||
}
|
||||
// If not, we will make a admin account where we will attach all admin messages
|
||||
if (!$userId) {
|
||||
$adminUserId = DB::table('users')->insertGetId([
|
||||
'first_name' => $message['admin'],
|
||||
'last_name' => '',
|
||||
'email' => 'admin+' . strtolower(str_replace(' ', '_', $message['admin'])) . '@paymenter.org',
|
||||
'password' => bcrypt(\Str::random(16)),
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
$userId = $adminUserId;
|
||||
}
|
||||
}
|
||||
if (!$userId && DB::table('users')->where('email', $message['email'])->exists()) {
|
||||
$userRecord = DB::table('users')->where('email', $message['email'])->first();
|
||||
$userId = $userRecord->id;
|
||||
}
|
||||
if (!$userId) {
|
||||
// Create a user with the email
|
||||
$newUserId = DB::table('users')->insertGetId([
|
||||
'first_name' => $message['name'],
|
||||
'last_name' => '',
|
||||
'email' => $message['email'],
|
||||
'password' => bcrypt(\Str::random(16)),
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
$userId = $newUserId;
|
||||
}
|
||||
}
|
||||
|
||||
return $userId;
|
||||
}
|
||||
|
||||
private function importTickets()
|
||||
{
|
||||
|
||||
$this->info('Importing tickets... (' . $this->count('tbltickets') . ' records)');
|
||||
|
||||
$this->migrateInBatch('tbltickets', 'SELECT * FROM tbltickets LIMIT :limit OFFSET :offset', function ($records) {
|
||||
$data = [];
|
||||
$messages = [];
|
||||
foreach ($records as $record) {
|
||||
// Get user
|
||||
$user = $this->getUserIdTicket($record, $record['userid']);
|
||||
|
||||
if (!$user) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$departmentStmt = $this->pdo->prepare('SELECT * FROM tblticketdepartments WHERE id = :id LIMIT 1');
|
||||
$departmentStmt->bindValue(':id', $record['did'], PDO::PARAM_INT);
|
||||
$departmentStmt->execute();
|
||||
$department = $departmentStmt->fetch(PDO::FETCH_ASSOC);
|
||||
if ($department) {
|
||||
// You can map department to category if needed
|
||||
$departmentName = $department['name'];
|
||||
}
|
||||
|
||||
$data[] = [
|
||||
'id' => $record['id'],
|
||||
'user_id' => $user,
|
||||
'subject' => $record['title'],
|
||||
'status' => match ($record['status']) {
|
||||
'Answered' => 'replied',
|
||||
'Closed' => 'closed',
|
||||
default => 'open',
|
||||
},
|
||||
'priority' => match ($record['urgency']) {
|
||||
'Low' => 'low',
|
||||
'Medium' => 'medium',
|
||||
'High' => 'high',
|
||||
default => 'medium',
|
||||
},
|
||||
'department' => $departmentName ?? null,
|
||||
'created_at' => $record['date'],
|
||||
'updated_at' => $record['lastreply'],
|
||||
];
|
||||
|
||||
// Get ticket messages
|
||||
$stmt = $this->pdo->prepare('SELECT * FROM tblticketreplies WHERE tid = :tid ORDER BY date ASC');
|
||||
$stmt->bindValue(':tid', $record['id'], PDO::PARAM_INT);
|
||||
$stmt->execute();
|
||||
$messageRecords = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
foreach ($messageRecords as $message) {
|
||||
// Get admin user if any
|
||||
$userId = $this->getUserIdTicket($message, $message['userid']);
|
||||
$messages[] = [
|
||||
'ticket_id' => $record['id'],
|
||||
'user_id' => $userId,
|
||||
'message' => $message['message'],
|
||||
'created_at' => $message['date'],
|
||||
'updated_at' => $message['date'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
DB::table('tickets')->insert($data);
|
||||
if (count($messages) > 0) {
|
||||
DB::table('ticket_messages')->insert($messages);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private function importOrders()
|
||||
{
|
||||
$this->info('Importing orders... (' . $this->count('tblorders') . ' records)');
|
||||
|
||||
$this->migrateInBatch('tblorders', 'SELECT * FROM tblorders LIMIT :limit OFFSET :offset', function ($records) {
|
||||
$data = [];
|
||||
foreach ($records as $record) {
|
||||
// Get currency from user
|
||||
$user = $this->pdo->prepare('SELECT * FROM tblcurrencies WHERE id = (SELECT currency FROM tblclients WHERE id = :client_id LIMIT 1) LIMIT 1');
|
||||
$user->bindValue(':client_id', $record['userid'], PDO::PARAM_INT);
|
||||
$user->execute();
|
||||
$user = $user->fetch(PDO::FETCH_ASSOC);
|
||||
if (!$user) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$data[] = [
|
||||
'id' => $record['id'],
|
||||
'user_id' => $record['userid'],
|
||||
'currency_code' => $user['code'],
|
||||
'created_at' => $record['date'],
|
||||
'updated_at' => $record['date'],
|
||||
];
|
||||
}
|
||||
|
||||
DB::table('orders')->insert($data);
|
||||
});
|
||||
}
|
||||
|
||||
private function importServices()
|
||||
{
|
||||
$this->info('Importing services... (' . $this->count('tblhosting') . ' records)');
|
||||
|
||||
$this->migrateInBatch('tblhosting', 'SELECT * FROM tblhosting LIMIT :limit OFFSET :offset', function ($records) {
|
||||
$data = [];
|
||||
foreach ($records as $record) {
|
||||
// Get currency from user
|
||||
$user = $this->pdo->prepare('SELECT * FROM tblcurrencies WHERE id = (SELECT currency FROM tblclients WHERE id = :client_id LIMIT 1) LIMIT 1');
|
||||
$user->bindValue(':client_id', $record['userid'], PDO::PARAM_INT);
|
||||
$user->execute();
|
||||
$user = $user->fetch(PDO::FETCH_ASSOC);
|
||||
if (!$user) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$planId = DB::table('plans')
|
||||
->where('priceable_id', $record['packageid'])
|
||||
->where('priceable_type', Product::class)
|
||||
->where('name', match ($record['billingcycle']) {
|
||||
'Monthly' => 'Monthly',
|
||||
'Quarterly' => 'Quarterly',
|
||||
'Semi-Annually' => 'Semiannually',
|
||||
'Annually' => 'Annually',
|
||||
'Biennially' => 'Biennially',
|
||||
'Triennially' => 'Triennially',
|
||||
'One Time' => 'One-Time',
|
||||
'Free Account' => 'Free',
|
||||
default => 'Monthly',
|
||||
})
|
||||
->first()?->id;
|
||||
|
||||
$data[] = [
|
||||
'id' => $record['id'],
|
||||
'order_id' => $record['orderid'],
|
||||
'product_id' => $record['packageid'],
|
||||
'status' => match ($record['domainstatus']) {
|
||||
'Active' => 'active',
|
||||
'Suspended' => 'suspended',
|
||||
'Terminated' => 'cancelled',
|
||||
'Cancelled' => 'cancelled',
|
||||
'Fraud' => 'cancelled',
|
||||
'Pending' => 'pending',
|
||||
default => 'pending',
|
||||
},
|
||||
'price' => $record['amount'],
|
||||
'quantity' => $record['qty'],
|
||||
'user_id' => $record['userid'],
|
||||
'plan_id' => $planId,
|
||||
'currency_code' => $user['code'],
|
||||
'expires_at' => $record['nextduedate'] != '0000-00-00' ? $record['nextduedate'] : null,
|
||||
'created_at' => $record['regdate'],
|
||||
'updated_at' => $record['regdate'],
|
||||
];
|
||||
}
|
||||
|
||||
DB::table('services')->insert($data);
|
||||
});
|
||||
}
|
||||
|
||||
private function importCancellations()
|
||||
{
|
||||
$this->info('Importing cancellations... (' . $this->count('tblcancelrequests') . ' records)');
|
||||
|
||||
$this->migrateInBatch('tblcancelrequests', 'SELECT * FROM tblcancelrequests LIMIT :limit OFFSET :offset', function ($records) {
|
||||
$data = [];
|
||||
foreach ($records as $record) {
|
||||
$data[] = [
|
||||
'id' => $record['id'],
|
||||
'service_id' => $record['relid'],
|
||||
'reason' => $record['reason'],
|
||||
'type' => $record['type'] == 'End of Billing Period' ? 'end_of_period' : 'immediate',
|
||||
'created_at' => $record['date'],
|
||||
'updated_at' => $record['date'],
|
||||
];
|
||||
}
|
||||
|
||||
DB::table('service_cancellations')->insert($data);
|
||||
});
|
||||
}
|
||||
|
||||
private function importInvoices()
|
||||
{
|
||||
$this->info('Importing invoices... (' . $this->count('tblinvoices') . ' records)');
|
||||
|
||||
$this->migrateInBatch('tblinvoices', 'SELECT * FROM tblinvoices LIMIT :limit OFFSET :offset', function ($records) {
|
||||
$data = [];
|
||||
foreach ($records as $record) {
|
||||
// Get currency from user
|
||||
$user = $this->pdo->prepare('SELECT * FROM tblcurrencies WHERE id = (SELECT currency FROM tblclients WHERE id = :client_id LIMIT 1) LIMIT 1');
|
||||
$user->bindValue(':client_id', $record['userid'], PDO::PARAM_INT);
|
||||
$user->execute();
|
||||
$user = $user->fetch(PDO::FETCH_ASSOC);
|
||||
if (!$user) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$data[] = [
|
||||
'id' => $record['id'],
|
||||
'number' => !empty($record['invoicenum']) ? $record['invoicenum'] : $record['id'],
|
||||
'user_id' => $record['userid'],
|
||||
'status' => match ($record['status']) {
|
||||
'Paid' => 'paid',
|
||||
'Unpaid' => 'unpaid',
|
||||
'Cancelled' => 'cancelled',
|
||||
'Refunded' => 'cancelled',
|
||||
default => 'unpaid',
|
||||
},
|
||||
'currency_code' => $user['code'],
|
||||
'created_at' => $record['date'],
|
||||
'updated_at' => $record['date'],
|
||||
];
|
||||
}
|
||||
|
||||
DB::table('invoices')->insert($data);
|
||||
});
|
||||
|
||||
// Set invoice number in settings to highest imported invoice number + 1
|
||||
$highestInvoiceNumber = DB::table('invoices')->max('number');
|
||||
Setting::updateOrCreate(
|
||||
['key' => 'invoice_number'],
|
||||
['value' => $highestInvoiceNumber + 1]
|
||||
);
|
||||
}
|
||||
|
||||
private function importInvoiceItems()
|
||||
{
|
||||
$this->info('Importing invoice items... (' . $this->count('tblinvoiceitems') . ' records)');
|
||||
|
||||
$this->migrateInBatch('tblinvoiceitems', 'SELECT * FROM tblinvoiceitems LIMIT :limit OFFSET :offset', function ($records) {
|
||||
$data = [];
|
||||
foreach ($records as $record) {
|
||||
$data[] = [
|
||||
'id' => $record['id'],
|
||||
'invoice_id' => $record['invoiceid'],
|
||||
'description' => $record['description'],
|
||||
'price' => $record['amount'],
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
'reference_type' => match ($record['type']) {
|
||||
'Hosting' => Service::class,
|
||||
'Domain' => null,
|
||||
'Addon' => null,
|
||||
'Upgrade' => null,
|
||||
default => null,
|
||||
},
|
||||
'reference_id' => $record['relid'],
|
||||
];
|
||||
}
|
||||
|
||||
DB::table('invoice_items')->insert($data);
|
||||
});
|
||||
}
|
||||
|
||||
private function importPayments()
|
||||
{
|
||||
$this->info('Importing payments... (' . $this->count('tblaccounts') . ' records)');
|
||||
|
||||
$this->migrateInBatch('tblaccounts', 'SELECT * FROM tblaccounts LIMIT :limit OFFSET :offset', function ($records) {
|
||||
$data = [];
|
||||
foreach ($records as $record) {
|
||||
// Get currency from user
|
||||
$user = $this->pdo->prepare('SELECT * FROM tblcurrencies WHERE id = (SELECT currency FROM tblclients WHERE id = :client_id LIMIT 1) LIMIT 1');
|
||||
$user->bindValue(':client_id', $record['userid'], PDO::PARAM_INT);
|
||||
$user->execute();
|
||||
$user = $user->fetch(PDO::FETCH_ASSOC);
|
||||
if (!$user) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$data[] = [
|
||||
'id' => $record['id'],
|
||||
'invoice_id' => $record['invoiceid'],
|
||||
'amount' => $record['amountin'],
|
||||
'transaction_id' => $record['transid'],
|
||||
'created_at' => $record['date'],
|
||||
'updated_at' => $record['date'],
|
||||
];
|
||||
}
|
||||
|
||||
DB::table('invoice_transactions')->insert($data);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -78,7 +78,7 @@ class Logs extends Command
|
||||
|
||||
if (!empty($matches[1])) {
|
||||
// Return the last error message trimmed
|
||||
return trim(end($matches[1]));
|
||||
return trim(end($matches[0]));
|
||||
}
|
||||
|
||||
return null;
|
||||
|
||||
@@ -173,6 +173,7 @@ class MigrateOldData extends Command
|
||||
// Remove all the pre-existing currencies, in case the user still want's to use single currency
|
||||
Currency::truncate();
|
||||
Currency::create([
|
||||
'name' => $this->currency_code,
|
||||
'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,
|
||||
|
||||
38
app/Console/Commands/ScheduleHeartbeatCommand.php
Normal file
38
app/Console/Commands/ScheduleHeartbeatCommand.php
Normal file
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\CronStat;
|
||||
use App\Models\Setting;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class ScheduleHeartbeatCommand extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'app:schedule-heartbeat-command';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'Heartbeat command to indicate the scheduler is running';
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*/
|
||||
public function handle()
|
||||
{
|
||||
Setting::updateOrCreate(
|
||||
[
|
||||
'key' => 'last_scheduler_run',
|
||||
'settingable_type' => CronStat::class,
|
||||
],
|
||||
['value' => now()->toDateTimeString()]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2,12 +2,12 @@
|
||||
|
||||
namespace App\Console\Commands\User;
|
||||
|
||||
use App\Helpers\NotificationHelper;
|
||||
use App\Models\User;
|
||||
use Hash;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Contracts\Console\PromptsForMissingInput;
|
||||
use Illuminate\Support\Facades\Password;
|
||||
use Throwable;
|
||||
use Str;
|
||||
|
||||
class PasswordReset extends Command implements PromptsForMissingInput
|
||||
{
|
||||
@@ -23,7 +23,7 @@ class PasswordReset extends Command implements PromptsForMissingInput
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'Send password reset email to a user';
|
||||
protected $description = 'Reset a user\'s password and show the new password in the console';
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
@@ -46,18 +46,23 @@ class PasswordReset extends Command implements PromptsForMissingInput
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
NotificationHelper::passwordResetNotification($user, [
|
||||
'url' => url(route('password.reset', [
|
||||
'token' => Password::createToken($user),
|
||||
'email' => $user->email,
|
||||
], false)),
|
||||
]);
|
||||
if (!$this->confirm("Are you sure you want to reset the password for user with email '{$email}'?")) {
|
||||
$this->info('Operation cancelled.');
|
||||
|
||||
$this->info("Password reset email sent successfully to '{$email}'");
|
||||
} catch (Throwable $e) {
|
||||
$this->error('Failed to send password reset email: ' . $e->getMessage());
|
||||
return;
|
||||
}
|
||||
|
||||
// Make a strong password
|
||||
$password = Str::password(16);
|
||||
$user->forceFill([
|
||||
'password' => Hash::make($password),
|
||||
])->setRememberToken(Str::random(60));
|
||||
|
||||
$user->save();
|
||||
|
||||
// Output the new password to the console
|
||||
$this->info("Password for user with email '{$email}' has been reset.");
|
||||
$this->info("New password: <options=bold;fg=red>{$password}</>");
|
||||
}
|
||||
|
||||
protected function promptForMissingArgumentsUsing(): array
|
||||
|
||||
10
app/Enums/InvoiceTransactionStatus.php
Normal file
10
app/Enums/InvoiceTransactionStatus.php
Normal file
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
enum InvoiceTransactionStatus: string
|
||||
{
|
||||
case Processing = 'processing';
|
||||
case Succeeded = 'succeeded';
|
||||
case Failed = 'failed';
|
||||
}
|
||||
11
app/Enums/NotificationEnabledStatus.php
Normal file
11
app/Enums/NotificationEnabledStatus.php
Normal file
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
enum NotificationEnabledStatus: string
|
||||
{
|
||||
case Force = 'force';
|
||||
case ChoiceOn = 'choice_on';
|
||||
case ChoiceOff = 'choice_off';
|
||||
case Never = 'never';
|
||||
}
|
||||
18
app/Events/Cart/Created.php
Normal file
18
app/Events/Cart/Created.php
Normal file
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
namespace App\Events\Cart;
|
||||
|
||||
use App\Models\Cart;
|
||||
use Illuminate\Broadcasting\InteractsWithSockets;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class Created
|
||||
{
|
||||
use Dispatchable, InteractsWithSockets, SerializesModels;
|
||||
|
||||
/**
|
||||
* Create a new event instance.
|
||||
*/
|
||||
public function __construct(public Cart $cart) {}
|
||||
}
|
||||
18
app/Events/Cart/Creating.php
Normal file
18
app/Events/Cart/Creating.php
Normal file
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
namespace App\Events\Cart;
|
||||
|
||||
use App\Models\Cart;
|
||||
use Illuminate\Broadcasting\InteractsWithSockets;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class Creating
|
||||
{
|
||||
use Dispatchable, InteractsWithSockets, SerializesModels;
|
||||
|
||||
/**
|
||||
* Create a new event instance.
|
||||
*/
|
||||
public function __construct(public Cart $cart) {}
|
||||
}
|
||||
18
app/Events/Cart/Deleted.php
Normal file
18
app/Events/Cart/Deleted.php
Normal file
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
namespace App\Events\Cart;
|
||||
|
||||
use App\Models\Cart;
|
||||
use Illuminate\Broadcasting\InteractsWithSockets;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class Deleted
|
||||
{
|
||||
use Dispatchable, InteractsWithSockets, SerializesModels;
|
||||
|
||||
/**
|
||||
* Create a new event instance.
|
||||
*/
|
||||
public function __construct(public Cart $cart) {}
|
||||
}
|
||||
18
app/Events/Cart/Deleting.php
Normal file
18
app/Events/Cart/Deleting.php
Normal file
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
namespace App\Events\Cart;
|
||||
|
||||
use App\Models\Cart;
|
||||
use Illuminate\Broadcasting\InteractsWithSockets;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class Deleting
|
||||
{
|
||||
use Dispatchable, InteractsWithSockets, SerializesModels;
|
||||
|
||||
/**
|
||||
* Create a new event instance.
|
||||
*/
|
||||
public function __construct(public Cart $cart) {}
|
||||
}
|
||||
18
app/Events/Cart/Updated.php
Normal file
18
app/Events/Cart/Updated.php
Normal file
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
namespace App\Events\Cart;
|
||||
|
||||
use App\Models\Cart;
|
||||
use Illuminate\Broadcasting\InteractsWithSockets;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class Updated
|
||||
{
|
||||
use Dispatchable, InteractsWithSockets, SerializesModels;
|
||||
|
||||
/**
|
||||
* Create a new event instance.
|
||||
*/
|
||||
public function __construct(public Cart $cart) {}
|
||||
}
|
||||
18
app/Events/Cart/Updating.php
Normal file
18
app/Events/Cart/Updating.php
Normal file
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
namespace App\Events\Cart;
|
||||
|
||||
use App\Models\Cart;
|
||||
use Illuminate\Broadcasting\InteractsWithSockets;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class Updating
|
||||
{
|
||||
use Dispatchable, InteractsWithSockets, SerializesModels;
|
||||
|
||||
/**
|
||||
* Create a new event instance.
|
||||
*/
|
||||
public function __construct(public Cart $cart) {}
|
||||
}
|
||||
18
app/Events/CartItem/Created.php
Normal file
18
app/Events/CartItem/Created.php
Normal file
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
namespace App\Events\CartItem;
|
||||
|
||||
use App\Models\CartItem;
|
||||
use Illuminate\Broadcasting\InteractsWithSockets;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class Created
|
||||
{
|
||||
use Dispatchable, InteractsWithSockets, SerializesModels;
|
||||
|
||||
/**
|
||||
* Create a new event instance.
|
||||
*/
|
||||
public function __construct(public CartItem $cartItem) {}
|
||||
}
|
||||
18
app/Events/CartItem/Creating.php
Normal file
18
app/Events/CartItem/Creating.php
Normal file
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
namespace App\Events\CartItem;
|
||||
|
||||
use App\Models\CartItem;
|
||||
use Illuminate\Broadcasting\InteractsWithSockets;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class Creating
|
||||
{
|
||||
use Dispatchable, InteractsWithSockets, SerializesModels;
|
||||
|
||||
/**
|
||||
* Create a new event instance.
|
||||
*/
|
||||
public function __construct(public CartItem $cartItem) {}
|
||||
}
|
||||
18
app/Events/CartItem/Deleted.php
Normal file
18
app/Events/CartItem/Deleted.php
Normal file
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
namespace App\Events\CartItem;
|
||||
|
||||
use App\Models\CartItem;
|
||||
use Illuminate\Broadcasting\InteractsWithSockets;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class Deleted
|
||||
{
|
||||
use Dispatchable, InteractsWithSockets, SerializesModels;
|
||||
|
||||
/**
|
||||
* Create a new event instance.
|
||||
*/
|
||||
public function __construct(public CartItem $cartItem) {}
|
||||
}
|
||||
18
app/Events/CartItem/Deleting.php
Normal file
18
app/Events/CartItem/Deleting.php
Normal file
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
namespace App\Events\CartItem;
|
||||
|
||||
use App\Models\CartItem;
|
||||
use Illuminate\Broadcasting\InteractsWithSockets;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class Deleting
|
||||
{
|
||||
use Dispatchable, InteractsWithSockets, SerializesModels;
|
||||
|
||||
/**
|
||||
* Create a new event instance.
|
||||
*/
|
||||
public function __construct(public CartItem $cartItem) {}
|
||||
}
|
||||
18
app/Events/CartItem/Updated.php
Normal file
18
app/Events/CartItem/Updated.php
Normal file
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
namespace App\Events\CartItem;
|
||||
|
||||
use App\Models\CartItem;
|
||||
use Illuminate\Broadcasting\InteractsWithSockets;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class Updated
|
||||
{
|
||||
use Dispatchable, InteractsWithSockets, SerializesModels;
|
||||
|
||||
/**
|
||||
* Create a new event instance.
|
||||
*/
|
||||
public function __construct(public CartItem $cartItem) {}
|
||||
}
|
||||
18
app/Events/CartItem/Updating.php
Normal file
18
app/Events/CartItem/Updating.php
Normal file
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
namespace App\Events\CartItem;
|
||||
|
||||
use App\Models\CartItem;
|
||||
use Illuminate\Broadcasting\InteractsWithSockets;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class Updating
|
||||
{
|
||||
use Dispatchable, InteractsWithSockets, SerializesModels;
|
||||
|
||||
/**
|
||||
* Create a new event instance.
|
||||
*/
|
||||
public function __construct(public CartItem $cartItem) {}
|
||||
}
|
||||
54
app/Events/Invoice/GeneratePdf.php
Normal file
54
app/Events/Invoice/GeneratePdf.php
Normal file
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
namespace App\Events\Invoice;
|
||||
|
||||
use App\Models\Invoice;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
|
||||
class GeneratePdf
|
||||
{
|
||||
use Dispatchable;
|
||||
|
||||
public function __construct(
|
||||
public Invoice $invoice,
|
||||
public $pdf = null,
|
||||
public $pdfPath = null, // For remote/file-based PDFs
|
||||
public $pdfContent = null, // For base64/binary content
|
||||
public $fileName = null,
|
||||
public $contentType = 'application/pdf'
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Set a PDF from file path
|
||||
*/
|
||||
public function setPdfFromPath(string $path, ?string $fileName = null): void
|
||||
{
|
||||
$this->pdfPath = $path;
|
||||
$this->fileName = $fileName ?? basename($path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a PDF from content (base64, binary, etc.)
|
||||
*/
|
||||
public function setPdfFromContent(string $content, ?string $fileName = null): void
|
||||
{
|
||||
$this->pdfContent = $content;
|
||||
$this->fileName = $fileName ?? 'invoice.pdf';
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a DomPDF instance
|
||||
*/
|
||||
public function setPdf($pdf): void
|
||||
{
|
||||
$this->pdf = $pdf;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if any PDF was provided
|
||||
*/
|
||||
public function hasPdf(): bool
|
||||
{
|
||||
return $this->pdf !== null || $this->pdfPath !== null || $this->pdfContent !== null;
|
||||
}
|
||||
}
|
||||
18
app/Events/Notification/Created.php
Normal file
18
app/Events/Notification/Created.php
Normal file
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
namespace App\Events\Notification;
|
||||
|
||||
use App\Models\Notification;
|
||||
use Illuminate\Broadcasting\InteractsWithSockets;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class Created
|
||||
{
|
||||
use Dispatchable, InteractsWithSockets, SerializesModels;
|
||||
|
||||
/**
|
||||
* Create a new event instance.
|
||||
*/
|
||||
public function __construct(public Notification $notification) {}
|
||||
}
|
||||
18
app/Events/Notification/Creating.php
Normal file
18
app/Events/Notification/Creating.php
Normal file
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
namespace App\Events\Notification;
|
||||
|
||||
use App\Models\Notification;
|
||||
use Illuminate\Broadcasting\InteractsWithSockets;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class Creating
|
||||
{
|
||||
use Dispatchable, InteractsWithSockets, SerializesModels;
|
||||
|
||||
/**
|
||||
* Create a new event instance.
|
||||
*/
|
||||
public function __construct(public Notification $notification) {}
|
||||
}
|
||||
18
app/Events/Notification/Deleted.php
Normal file
18
app/Events/Notification/Deleted.php
Normal file
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
namespace App\Events\Notification;
|
||||
|
||||
use App\Models\Notification;
|
||||
use Illuminate\Broadcasting\InteractsWithSockets;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class Deleted
|
||||
{
|
||||
use Dispatchable, InteractsWithSockets, SerializesModels;
|
||||
|
||||
/**
|
||||
* Create a new event instance.
|
||||
*/
|
||||
public function __construct(public Notification $notification) {}
|
||||
}
|
||||
18
app/Events/Notification/Deleting.php
Normal file
18
app/Events/Notification/Deleting.php
Normal file
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
namespace App\Events\Notification;
|
||||
|
||||
use App\Models\Notification;
|
||||
use Illuminate\Broadcasting\InteractsWithSockets;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class Deleting
|
||||
{
|
||||
use Dispatchable, InteractsWithSockets, SerializesModels;
|
||||
|
||||
/**
|
||||
* Create a new event instance.
|
||||
*/
|
||||
public function __construct(public Notification $notification) {}
|
||||
}
|
||||
18
app/Events/Notification/Updated.php
Normal file
18
app/Events/Notification/Updated.php
Normal file
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
namespace App\Events\Notification;
|
||||
|
||||
use App\Models\Notification;
|
||||
use Illuminate\Broadcasting\InteractsWithSockets;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class Updated
|
||||
{
|
||||
use Dispatchable, InteractsWithSockets, SerializesModels;
|
||||
|
||||
/**
|
||||
* Create a new event instance.
|
||||
*/
|
||||
public function __construct(public Notification $notification) {}
|
||||
}
|
||||
18
app/Events/Notification/Updating.php
Normal file
18
app/Events/Notification/Updating.php
Normal file
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
namespace App\Events\Notification;
|
||||
|
||||
use App\Models\Notification;
|
||||
use Illuminate\Broadcasting\InteractsWithSockets;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class Updating
|
||||
{
|
||||
use Dispatchable, InteractsWithSockets, SerializesModels;
|
||||
|
||||
/**
|
||||
* Create a new event instance.
|
||||
*/
|
||||
public function __construct(public Notification $notification) {}
|
||||
}
|
||||
@@ -15,7 +15,7 @@ class Saved
|
||||
/**
|
||||
* Create a new event instance.
|
||||
*/
|
||||
public function __construct(Setting $setting)
|
||||
public function __construct(public Setting $setting)
|
||||
{
|
||||
// This event is dispatched after a setting is saved.
|
||||
// We are gonna overwrite the value of the setting
|
||||
|
||||
32
app/Exceptions/ErrorHandler.php
Normal file
32
app/Exceptions/ErrorHandler.php
Normal file
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace App\Exceptions;
|
||||
|
||||
use Illuminate\Foundation\Exceptions\Handler;
|
||||
use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface;
|
||||
|
||||
class ErrorHandler extends Handler
|
||||
{
|
||||
// We are overriding this method to change the view namespace separator from '::' to '.' (so themes can override error views)
|
||||
/**
|
||||
* Get the view used to render HTTP exceptions.
|
||||
*
|
||||
* @return string|null
|
||||
*/
|
||||
protected function getHttpExceptionView(HttpExceptionInterface $e)
|
||||
{
|
||||
$view = 'errors.' . $e->getStatusCode();
|
||||
|
||||
if (view()->exists($view)) {
|
||||
return $view;
|
||||
}
|
||||
|
||||
$view = substr($view, 0, -2) . 'xx';
|
||||
|
||||
if (view()->exists($view)) {
|
||||
return $view;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -4,22 +4,28 @@ namespace App\Helpers;
|
||||
|
||||
use App\Attributes\ExtensionMeta;
|
||||
use App\Classes\FilamentInput;
|
||||
use App\Enums\InvoiceTransactionStatus;
|
||||
use App\Models\BillingAgreement;
|
||||
use App\Models\Extension;
|
||||
use App\Models\Gateway;
|
||||
use App\Models\Invoice;
|
||||
use App\Models\InvoiceTransaction;
|
||||
use App\Models\Product;
|
||||
use App\Models\Server;
|
||||
use App\Models\Service;
|
||||
use App\Models\User;
|
||||
use Exception;
|
||||
use Filament\Forms\Components\Placeholder;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Illuminate\Database\Migrations\Migrator;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Livewire\Features\SupportFileUploads\TemporaryUploadedFile;
|
||||
use OwenIt\Auditing\Events\AuditCustom;
|
||||
use ReflectionClass;
|
||||
|
||||
class ExtensionHelper
|
||||
@@ -162,14 +168,10 @@ class ExtensionHelper
|
||||
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,
|
||||
'meta' => self::getMeta($class),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -190,13 +192,11 @@ class ExtensionHelper
|
||||
|
||||
// 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,
|
||||
'meta' => self::getMeta('\\Paymenter\\Extensions\\' . ucfirst($type) . 's\\' . $name . '\\' . $name),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -205,6 +205,14 @@ class ExtensionHelper
|
||||
return $extensions;
|
||||
}
|
||||
|
||||
public static function getMeta($class)
|
||||
{
|
||||
$reflection = new ReflectionClass($class);
|
||||
$attributes = $reflection->getAttributes(ExtensionMeta::class);
|
||||
|
||||
return $attributes ? $attributes[0]->newInstance() : null;
|
||||
}
|
||||
|
||||
public static function getInstallableExtensions()
|
||||
{
|
||||
$extensions = self::getExtensions('other');
|
||||
@@ -224,8 +232,14 @@ class ExtensionHelper
|
||||
|
||||
return self::getExtension($extension->type, $extension->extension, $extension->settings)->$function(...$args);
|
||||
} catch (Exception $e) {
|
||||
// If mayFail is true, just report the exception instead of throwing it
|
||||
if (!$mayFail) {
|
||||
throw $e;
|
||||
} else {
|
||||
// If extension error is Not Found, don't report
|
||||
if (\Str::doesntEndWith($e->getMessage(), 'not found')) {
|
||||
report($e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -366,7 +380,7 @@ class ExtensionHelper
|
||||
{
|
||||
$gateways = [];
|
||||
|
||||
foreach (Gateway::all() as $gateway) {
|
||||
foreach (Gateway::with('settings')->get() as $gateway) {
|
||||
if (self::hasFunction($gateway, 'canUseGateway')) {
|
||||
if (self::getExtension('gateway', $gateway->extension, $gateway->settings)->canUseGateway($total, $currency, $type, $items)) {
|
||||
$gateways[] = $gateway;
|
||||
@@ -387,13 +401,71 @@ class ExtensionHelper
|
||||
return self::getExtension('gateway', $gateway->extension, $gateway->settings)->pay($invoice, $invoice->remaining);
|
||||
}
|
||||
|
||||
public static function charge(Gateway $gateway, Invoice $invoice, BillingAgreement $billingAgreement): bool
|
||||
{
|
||||
return self::getExtension('gateway', $gateway->extension, $gateway->settings)->charge($invoice, $invoice->remaining, $billingAgreement);
|
||||
}
|
||||
|
||||
public static function getBillingAgreementGateways($currency)
|
||||
{
|
||||
$gateways = [];
|
||||
|
||||
foreach (Gateway::with('settings')->get() as $gateway) {
|
||||
if (self::hasFunction($gateway, 'supportsBillingAgreements')) {
|
||||
if (self::getExtension('gateway', $gateway->extension, $gateway->settings)->supportsBillingAgreements($currency)) {
|
||||
$gateways[] = $gateway;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $gateways;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create billing agreement
|
||||
*
|
||||
* @param \App\Models\User $user
|
||||
* @param \App\Models\Gateway $gateway
|
||||
* @return string|view
|
||||
*/
|
||||
public static function createBillingAgreement($user, $gateway)
|
||||
{
|
||||
return self::getExtension('gateway', $gateway->extension, $gateway->settings)->createBillingAgreement($user);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel billing agreement
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public static function cancelBillingAgreement(BillingAgreement $billingAgreement)
|
||||
{
|
||||
return self::getExtension('gateway', $billingAgreement->gateway->extension, $billingAgreement->gateway->settings)->cancelBillingAgreement($billingAgreement);
|
||||
}
|
||||
|
||||
public static function makeBillingAgreement(User $user, $gateway, $name, $externalReference, $type = null, $expiry = null)
|
||||
{
|
||||
$gateway = Gateway::where('extension', $gateway)->firstOrFail();
|
||||
|
||||
$billingAgreement = BillingAgreement::updateOrCreate([
|
||||
'external_reference' => $externalReference,
|
||||
'user_id' => $user->id,
|
||||
'gateway_id' => $gateway->id,
|
||||
], [
|
||||
'name' => $name,
|
||||
'type' => $type,
|
||||
'expiry' => $expiry,
|
||||
]);
|
||||
|
||||
return $billingAgreement;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add payment to invoice
|
||||
*
|
||||
* @param Invoice|int $invoice
|
||||
* @param Gateway|null $gateway
|
||||
*/
|
||||
public static function addPayment($invoice, $gateway, $amount, $fee = null, $transactionId = null)
|
||||
public static function addPayment($invoice, $gateway, $amount, $fee = null, $transactionId = null, InvoiceTransactionStatus $status = InvoiceTransactionStatus::Succeeded, $isCreditTransaction = false)
|
||||
{
|
||||
if (isset($gateway)) {
|
||||
$gateway = Gateway::where('extension', $gateway)->first();
|
||||
@@ -403,26 +475,54 @@ class ExtensionHelper
|
||||
|
||||
if (!$transactionId) {
|
||||
$transaction = $invoice->transactions()->create([
|
||||
'gateway_id' => $gateway ? $gateway->id : null,
|
||||
'gateway_id' => $gateway?->id,
|
||||
'amount' => $amount,
|
||||
'fee' => $fee,
|
||||
'status' => $status,
|
||||
'is_credit_transaction' => $isCreditTransaction,
|
||||
]);
|
||||
} else {
|
||||
$updateData = [
|
||||
'gateway_id' => $gateway?->id,
|
||||
'amount' => $amount,
|
||||
'status' => $status,
|
||||
'is_credit_transaction' => $isCreditTransaction,
|
||||
];
|
||||
if ($fee !== null) {
|
||||
$updateData['fee'] = $fee;
|
||||
}
|
||||
|
||||
$transaction = $invoice->transactions()->updateOrCreate(
|
||||
[
|
||||
'transaction_id' => $transactionId,
|
||||
],
|
||||
[
|
||||
'gateway_id' => $gateway ? $gateway->id : null,
|
||||
'amount' => $amount,
|
||||
'fee' => $fee,
|
||||
]
|
||||
$updateData
|
||||
);
|
||||
}
|
||||
|
||||
return $transaction;
|
||||
}
|
||||
|
||||
public static function addProcessingPayment($invoice, $gateway, $amount, $fee = null, $transactionId = null)
|
||||
{
|
||||
return self::addPayment($invoice, $gateway, $amount, $fee, $transactionId, InvoiceTransactionStatus::Processing);
|
||||
}
|
||||
|
||||
public static function addFailedPayment($invoice, $gateway, $amount, $fee = null, $transactionId = null)
|
||||
{
|
||||
return self::addPayment($invoice, $gateway, $amount, $fee, $transactionId, InvoiceTransactionStatus::Failed);
|
||||
}
|
||||
|
||||
public static function addPaymentFee($transactionId, $fee)
|
||||
{
|
||||
$transaction = InvoiceTransaction::where('transaction_id', $transactionId)->firstOrFail();
|
||||
|
||||
$transaction->fee = $fee;
|
||||
$transaction->save();
|
||||
|
||||
return $transaction;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel subscription
|
||||
*/
|
||||
@@ -473,6 +573,17 @@ class ExtensionHelper
|
||||
return $server;
|
||||
}
|
||||
|
||||
protected static function recordAudit(Model $model, string $action, array $oldValues = [], array $newValues = [])
|
||||
{
|
||||
// Trigger audit log for server creation
|
||||
$model->auditEvent = $action;
|
||||
$model->isCustomEvent = true;
|
||||
$model->auditCustomOld = $oldValues;
|
||||
$model->auditCustomNew = $newValues;
|
||||
|
||||
Event::dispatch(new AuditCustom($model));
|
||||
}
|
||||
|
||||
/**
|
||||
* Create server
|
||||
*/
|
||||
@@ -480,6 +591,8 @@ class ExtensionHelper
|
||||
{
|
||||
$server = self::checkServer($service, 'createServer');
|
||||
|
||||
self::recordAudit($service, 'extension_action', [], ['action' => 'create_server']);
|
||||
|
||||
return self::getExtension('server', $server->extension, $server->settings)->createServer($service, self::settingsToArray($service->product->settings), self::getServiceProperties($service));
|
||||
}
|
||||
|
||||
@@ -490,6 +603,8 @@ class ExtensionHelper
|
||||
{
|
||||
$server = self::checkServer($service, 'suspendServer');
|
||||
|
||||
self::recordAudit($service, 'extension_action', [], ['action' => 'suspend_server']);
|
||||
|
||||
return self::getExtension('server', $server->extension, $server->settings)->suspendServer($service, self::settingsToArray($service->product->settings), self::getServiceProperties($service));
|
||||
}
|
||||
|
||||
@@ -500,6 +615,8 @@ class ExtensionHelper
|
||||
{
|
||||
$server = self::checkServer($service, 'unsuspendServer');
|
||||
|
||||
self::recordAudit($service, 'extension_action', [], ['action' => 'unsuspend_server']);
|
||||
|
||||
return self::getExtension('server', $server->extension, $server->settings)->unsuspendServer($service, self::settingsToArray($service->product->settings), self::getServiceProperties($service));
|
||||
}
|
||||
|
||||
@@ -510,6 +627,8 @@ class ExtensionHelper
|
||||
{
|
||||
$server = self::checkServer($service, 'terminateServer');
|
||||
|
||||
self::recordAudit($service, 'extension_action', [], ['action' => 'terminate_server']);
|
||||
|
||||
return self::getExtension('server', $server->extension, $server->settings)->terminateServer($service, self::settingsToArray($service->product->settings), self::getServiceProperties($service));
|
||||
}
|
||||
|
||||
@@ -520,6 +639,8 @@ class ExtensionHelper
|
||||
{
|
||||
$server = self::checkServer($service, 'upgradeServer');
|
||||
|
||||
self::recordAudit($service, 'extension_action', [], ['action' => 'upgrade_server']);
|
||||
|
||||
return self::getExtension('server', $server->extension, $server->settings)->upgradeServer($service, self::settingsToArray($service->product->settings), self::getServiceProperties($service));
|
||||
}
|
||||
|
||||
@@ -574,7 +695,7 @@ class ExtensionHelper
|
||||
DB::table('migrations')->where('migration', $migrationName)->delete();
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
Log::error('Failed to rollback migration: ' . $migrationName . ' - ' . $e->getMessage());
|
||||
report($e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -584,15 +705,14 @@ class ExtensionHelper
|
||||
*/
|
||||
public static function runMigrations($path)
|
||||
{
|
||||
$migrator = app(Migrator::class);
|
||||
|
||||
try {
|
||||
Artisan::call('migrate', [
|
||||
'--path' => $path,
|
||||
'--force' => true,
|
||||
]);
|
||||
$output = Artisan::output();
|
||||
Log::debug('Migrations output: ' . $output);
|
||||
$ranMigrations = $migrator->run(base_path($path));
|
||||
|
||||
Log::debug('Migrations output: ', $ranMigrations);
|
||||
} catch (Exception $e) {
|
||||
Log::error('Failed to run migrations for path: ' . $path . ' - ' . $e->getMessage());
|
||||
report($e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,8 +5,9 @@ namespace App\Helpers;
|
||||
use App\Classes\PDF;
|
||||
use App\Mail\Mail;
|
||||
use App\Models\EmailLog;
|
||||
use App\Models\EmailTemplate;
|
||||
use App\Models\Invoice;
|
||||
use App\Models\Notification;
|
||||
use App\Models\NotificationTemplate;
|
||||
use App\Models\Order;
|
||||
use App\Models\Service;
|
||||
use App\Models\ServiceCancellation;
|
||||
@@ -16,6 +17,7 @@ use Carbon\Carbon;
|
||||
use Illuminate\Support\Facades\Config;
|
||||
use Illuminate\Support\Facades\Mail as FacadesMail;
|
||||
use Illuminate\Support\Facades\URL;
|
||||
use Illuminate\View\Compilers\BladeCompiler;
|
||||
|
||||
class NotificationHelper
|
||||
{
|
||||
@@ -23,16 +25,12 @@ class NotificationHelper
|
||||
* Send an email notification.
|
||||
*/
|
||||
public static function sendEmailNotification(
|
||||
$emailTemplateKey,
|
||||
NotificationTemplate $notificationTemplate,
|
||||
array $data,
|
||||
User $user,
|
||||
array $attachments = []
|
||||
): void {
|
||||
$emailTemplate = EmailTemplate::where('key', $emailTemplateKey)->first();
|
||||
if (!$emailTemplate || !$emailTemplate->enabled || config('settings.mail_disable')) {
|
||||
return;
|
||||
}
|
||||
$mail = new Mail($emailTemplate, $data);
|
||||
$mail = new Mail($notificationTemplate, $data);
|
||||
|
||||
$emailLog = EmailLog::create([
|
||||
'user_id' => $user->id,
|
||||
@@ -49,17 +47,91 @@ class NotificationHelper
|
||||
}
|
||||
|
||||
FacadesMail::to($user->email)
|
||||
->bcc($emailTemplate->bcc)
|
||||
->cc($emailTemplate->cc)
|
||||
->bcc($notificationTemplate->bcc)
|
||||
->cc($notificationTemplate->cc)
|
||||
->queue($mail);
|
||||
}
|
||||
|
||||
public static function sendSystemEmailNotification(
|
||||
string $subject,
|
||||
string $body,
|
||||
array $attachments = [],
|
||||
?string $email = null,
|
||||
): void {
|
||||
if (!$email) {
|
||||
$email = config('settings.system_email_address');
|
||||
}
|
||||
if (!$email || config('settings.mail_disable')) {
|
||||
return;
|
||||
}
|
||||
$mail = new \App\Mail\SystemMail([
|
||||
'subject' => $subject,
|
||||
'body' => $body,
|
||||
]);
|
||||
$emailLog = EmailLog::create([
|
||||
'subject' => $mail->envelope()->subject,
|
||||
'to' => $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($email)
|
||||
->queue($mail);
|
||||
}
|
||||
|
||||
public static function sendInAppNotification(
|
||||
NotificationTemplate $notification,
|
||||
array $data,
|
||||
User $user,
|
||||
bool $show_in_app = true,
|
||||
bool $show_as_push = true
|
||||
): void {
|
||||
Notification::create([
|
||||
'user_id' => $user->id,
|
||||
'title' => BladeCompiler::render($notification->in_app_title, $data),
|
||||
'body' => BladeCompiler::render($notification->in_app_body, $data),
|
||||
'url' => isset($notification->in_app_url) ? BladeCompiler::render($notification->in_app_url, $data) : null,
|
||||
'show_in_app' => $show_in_app,
|
||||
'show_as_push' => $show_as_push,
|
||||
]);
|
||||
}
|
||||
|
||||
public static function sendNotification(
|
||||
$notificationTemplateKey,
|
||||
array $data,
|
||||
User $user,
|
||||
array $attachments = [],
|
||||
bool $show_in_app = true,
|
||||
bool $show_as_push = true
|
||||
): void {
|
||||
$notification = NotificationTemplate::where('key', $notificationTemplateKey)->first();
|
||||
if (!$notification || !$notification->enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
$userPreference = $user->notificationsPreferences()->where('notification_template_id', $notification->id)->first();
|
||||
|
||||
if ($notification->isEnabledForPreference($userPreference, 'mail') && !config('settings.mail_disable')) {
|
||||
self::sendEmailNotification($notification, $data, $user, $attachments);
|
||||
}
|
||||
|
||||
if ($notification->isEnabledForPreference($userPreference, 'app')) {
|
||||
self::sendInAppNotification($notification, $data, $user, $show_in_app, $show_as_push);
|
||||
}
|
||||
}
|
||||
|
||||
public static function loginDetectedNotification(User $user, array $data = []): void
|
||||
{
|
||||
self::sendEmailNotification('new_login_detected', $data, $user);
|
||||
self::sendNotification('new_login_detected', $data, $user);
|
||||
}
|
||||
|
||||
public static function invoiceCreatedNotification(User $user, Invoice $invoice): void
|
||||
public static function invoiceNotification(User $user, Invoice $invoice, $key = 'new_invoice_created'): void
|
||||
{
|
||||
$data = [
|
||||
'invoice' => $invoice,
|
||||
@@ -76,18 +148,33 @@ class NotificationHelper
|
||||
mkdir(storage_path('app/invoices'), 0755, true);
|
||||
}
|
||||
// Save the PDF to a temporary location
|
||||
$pdfPath = storage_path('app/invoices/' . $invoice->number . '.pdf');
|
||||
$pdfPath = storage_path('app/invoices/' . ($invoice->number ?? $invoice->id) . '.pdf');
|
||||
$pdf->save($pdfPath);
|
||||
|
||||
// Attach the PDF to the email
|
||||
$attachments = [
|
||||
[
|
||||
'path' => 'invoices/' . $invoice->number . '.pdf',
|
||||
'path' => 'invoices/' . ($invoice->number ?? $invoice->id) . '.pdf',
|
||||
'name' => 'invoice.pdf',
|
||||
],
|
||||
];
|
||||
|
||||
self::sendEmailNotification('new_invoice_created', $data, $user, $attachments);
|
||||
self::sendNotification($key, $data, $user, $attachments);
|
||||
}
|
||||
|
||||
public static function invoiceCreatedNotification(User $user, Invoice $invoice): void
|
||||
{
|
||||
self::invoiceNotification($user, $invoice, 'new_invoice_created');
|
||||
}
|
||||
|
||||
public static function invoicePaidNotification(User $user, Invoice $invoice): void
|
||||
{
|
||||
self::invoiceNotification($user, $invoice, 'invoice_paid');
|
||||
}
|
||||
|
||||
public static function invoicePaymentFailedNotification(User $user, Invoice $invoice): void
|
||||
{
|
||||
self::invoiceNotification($user, $invoice, 'invoice_payment_failed');
|
||||
}
|
||||
|
||||
public static function orderCreatedNotification(User $user, Order $order, array $data = []): void
|
||||
@@ -97,31 +184,31 @@ class NotificationHelper
|
||||
'items' => $order->services,
|
||||
'total' => $order->formattedTotal,
|
||||
];
|
||||
self::sendEmailNotification('new_order_created', $data, $user);
|
||||
self::sendNotification('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);
|
||||
self::sendNotification('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);
|
||||
self::sendNotification('server_suspended', $data, $user);
|
||||
}
|
||||
|
||||
public static function serverTerminatedNotification(User $user, Service $service, array $data = []): void
|
||||
{
|
||||
$data['service'] = $service;
|
||||
self::sendEmailNotification('server_terminated', $data, $user);
|
||||
self::sendNotification('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);
|
||||
self::sendNotification('new_ticket_message', $data, $user);
|
||||
}
|
||||
|
||||
public static function emailVerificationNotification(User $user, array $data = []): void
|
||||
@@ -135,19 +222,19 @@ class NotificationHelper
|
||||
'hash' => sha1($user->email),
|
||||
]
|
||||
);
|
||||
self::sendEmailNotification('email_verification', $data, $user);
|
||||
self::sendNotification('email_verification', $data, $user);
|
||||
}
|
||||
|
||||
public static function passwordResetNotification(User $user, array $data = []): void
|
||||
{
|
||||
$data['user'] = $user;
|
||||
self::sendEmailNotification('password_reset', $data, $user);
|
||||
self::sendNotification('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);
|
||||
self::sendNotification('service_cancellation_received', $data, $user);
|
||||
}
|
||||
}
|
||||
|
||||
89
app/Http/Controllers/Api/Admin/CreditController.php
Normal file
89
app/Http/Controllers/Api/Admin/CreditController.php
Normal file
@@ -0,0 +1,89 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Admin;
|
||||
|
||||
use App\Http\Controllers\Api\ApiController;
|
||||
use App\Http\Requests\Api\Admin\Credits\CreateCreditRequest;
|
||||
use App\Http\Requests\Api\Admin\Credits\DeleteCreditRequest;
|
||||
use App\Http\Requests\Api\Admin\Credits\GetCreditRequest;
|
||||
use App\Http\Requests\Api\Admin\Credits\GetCreditsRequest;
|
||||
use App\Http\Requests\Api\Admin\Credits\UpdateCreditRequest;
|
||||
use App\Http\Resources\CreditResource;
|
||||
use App\Models\Credit;
|
||||
use Dedoc\Scramble\Attributes\Group;
|
||||
use Dedoc\Scramble\Attributes\QueryParameter;
|
||||
use Spatie\QueryBuilder\QueryBuilder;
|
||||
|
||||
#[Group(name: 'Credits', weight: 4)]
|
||||
class CreditController extends ApiController
|
||||
{
|
||||
protected const INCLUDES = [
|
||||
'user',
|
||||
];
|
||||
|
||||
/**
|
||||
* List Credits
|
||||
*/
|
||||
#[QueryParameter('per_page', 'How many items to show per page.', type: 'int', default: 15, example: 20)]
|
||||
#[QueryParameter('page', 'Which page to show.', type: 'int', example: 2)]
|
||||
public function index(GetCreditsRequest $request)
|
||||
{
|
||||
// Fetch credits with pagination
|
||||
$credits = QueryBuilder::for(Credit::class)
|
||||
->allowedFilters(['id', 'currency_code', 'user_id'])
|
||||
->allowedIncludes($this->allowedIncludes(self::INCLUDES))
|
||||
->allowedSorts(['id', 'created_at', 'updated_at', 'currency_code'])
|
||||
->simplePaginate(request('per_page', 15));
|
||||
|
||||
// Return the credits as a JSON response
|
||||
return CreditResource::collection($credits);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new credit
|
||||
*/
|
||||
public function store(CreateCreditRequest $request)
|
||||
{
|
||||
// Validate and create the credit
|
||||
$credit = Credit::create($request->validated());
|
||||
|
||||
// Return the created credit as a JSON response
|
||||
return new CreditResource($credit);
|
||||
}
|
||||
|
||||
/**
|
||||
* Show a specific credit
|
||||
*/
|
||||
public function show(GetCreditRequest $request, Credit $credit)
|
||||
{
|
||||
$credit = QueryBuilder::for(Credit::class)
|
||||
->allowedIncludes($this->allowedIncludes(self::INCLUDES))
|
||||
->findOrFail($credit->id);
|
||||
|
||||
// Return the credit as a JSON response
|
||||
return new CreditResource($credit);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a specific credit
|
||||
*/
|
||||
public function update(UpdateCreditRequest $request, Credit $credit)
|
||||
{
|
||||
// Validate and update the credit
|
||||
$credit->update($request->validated());
|
||||
|
||||
// Return the updated credit as a JSON response
|
||||
return new CreditResource($credit);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a specific credit
|
||||
*/
|
||||
public function destroy(DeleteCreditRequest $request, Credit $credit)
|
||||
{
|
||||
// Delete the credit
|
||||
$credit->delete();
|
||||
|
||||
return $this->returnNoContent();
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@ class OrderController extends ApiController
|
||||
{
|
||||
protected const INCLUDES = [
|
||||
'services',
|
||||
'user',
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -19,6 +19,8 @@ class TicketController extends ApiController
|
||||
{
|
||||
protected const INCLUDES = [
|
||||
'messages',
|
||||
'user',
|
||||
'assigned_to',
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -30,9 +32,9 @@ class TicketController extends ApiController
|
||||
{
|
||||
// Fetch tickets with pagination
|
||||
$tickets = QueryBuilder::for(Ticket::class)
|
||||
->allowedFilters(['id', 'currency_code'])
|
||||
->allowedFilters(['id', 'currency_code', 'user_id', 'status', 'priority', 'department'])
|
||||
->allowedIncludes($this->allowedIncludes(self::INCLUDES))
|
||||
->allowedSorts(['id', 'created_at', 'updated_at', 'currency_code'])
|
||||
->allowedSorts(['id', 'created_at', 'updated_at', 'currency_code', 'status', 'priority', 'department'])
|
||||
->simplePaginate(request('per_page', 15));
|
||||
|
||||
// Return the tickets as a JSON response
|
||||
|
||||
@@ -19,6 +19,7 @@ class TicketMessageController extends ApiController
|
||||
protected const INCLUDES = [
|
||||
'user',
|
||||
'ticket',
|
||||
'attachments',
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
70
app/Http/Middleware/CheckoutParameterMiddleware.php
Normal file
70
app/Http/Middleware/CheckoutParameterMiddleware.php
Normal file
@@ -0,0 +1,70 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use App\Classes\Cart;
|
||||
use App\Exceptions\DisplayException;
|
||||
use App\Models\Currency;
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class CheckoutParameterMiddleware
|
||||
{
|
||||
/**
|
||||
* Handle an incoming request.
|
||||
*
|
||||
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
|
||||
*/
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
$this->handleCurrencySwitch($request);
|
||||
$this->handleCouponApplication($request);
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle currency switching if requested
|
||||
*/
|
||||
private function handleCurrencySwitch(Request $request): void
|
||||
{
|
||||
if (!$request->has('currency')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$currencyCode = $request->input('currency');
|
||||
|
||||
// Don't allow currency changes if cart has items or currency doesn't exist
|
||||
if ($this->shouldBlockCurrencyChange($currencyCode)) {
|
||||
return;
|
||||
}
|
||||
|
||||
session(['currency' => $currencyCode]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle coupon application if requested
|
||||
*/
|
||||
private function handleCouponApplication(Request $request): void
|
||||
{
|
||||
if (!$request->has('coupon')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
Cart::applyCoupon($request->input('coupon'));
|
||||
} catch (DisplayException $e) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if currency change should be blocked
|
||||
*/
|
||||
private function shouldBlockCurrencyChange(string $currencyCode): bool
|
||||
{
|
||||
return Cart::items()->count() > 0 ||
|
||||
Currency::where('code', $currencyCode)->doesntExist();
|
||||
}
|
||||
}
|
||||
27
app/Http/Requests/Api/Admin/Credits/CreateCreditRequest.php
Normal file
27
app/Http/Requests/Api/Admin/Credits/CreateCreditRequest.php
Normal file
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Api\Admin\Credits;
|
||||
|
||||
use App\Http\Requests\Api\Admin\AdminApiRequest;
|
||||
|
||||
class CreateCreditRequest extends AdminApiRequest
|
||||
{
|
||||
protected $permission = 'credits.create';
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'user_id' => 'required|exists:users,id',
|
||||
/**
|
||||
* @example USD
|
||||
*/
|
||||
'currency_code' => [
|
||||
'required',
|
||||
'string',
|
||||
'exists:currencies,code',
|
||||
'unique:credits,currency_code,NULL,id,user_id,' . $this->input('user_id'),
|
||||
],
|
||||
'amount' => 'required|numeric|min:0',
|
||||
];
|
||||
}
|
||||
}
|
||||
10
app/Http/Requests/Api/Admin/Credits/DeleteCreditRequest.php
Normal file
10
app/Http/Requests/Api/Admin/Credits/DeleteCreditRequest.php
Normal file
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Api\Admin\Credits;
|
||||
|
||||
use App\Http\Requests\Api\Admin\AdminApiRequest;
|
||||
|
||||
class DeleteCreditRequest extends AdminApiRequest
|
||||
{
|
||||
protected $permission = 'credits.delete';
|
||||
}
|
||||
10
app/Http/Requests/Api/Admin/Credits/GetCreditRequest.php
Normal file
10
app/Http/Requests/Api/Admin/Credits/GetCreditRequest.php
Normal file
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Api\Admin\Credits;
|
||||
|
||||
use App\Http\Requests\Api\Admin\AdminApiRequest;
|
||||
|
||||
class GetCreditRequest extends AdminApiRequest
|
||||
{
|
||||
protected $permission = 'credits.view';
|
||||
}
|
||||
10
app/Http/Requests/Api/Admin/Credits/GetCreditsRequest.php
Normal file
10
app/Http/Requests/Api/Admin/Credits/GetCreditsRequest.php
Normal file
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Api\Admin\Credits;
|
||||
|
||||
use App\Http\Requests\Api\Admin\AdminApiRequest;
|
||||
|
||||
class GetCreditsRequest extends AdminApiRequest
|
||||
{
|
||||
protected $permission = 'credits.view';
|
||||
}
|
||||
27
app/Http/Requests/Api/Admin/Credits/UpdateCreditRequest.php
Normal file
27
app/Http/Requests/Api/Admin/Credits/UpdateCreditRequest.php
Normal file
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Api\Admin\Credits;
|
||||
|
||||
use App\Http\Requests\Api\Admin\AdminApiRequest;
|
||||
|
||||
class UpdateCreditRequest extends AdminApiRequest
|
||||
{
|
||||
protected $permission = 'credits.update';
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
/**
|
||||
* @example USD
|
||||
*/
|
||||
'currency_code' => [
|
||||
'sometimes',
|
||||
'required',
|
||||
'string',
|
||||
'exists:currencies,code',
|
||||
'unique:credits,currency_code,' . $this->route('credit')->id . ',id,user_id,' . $this->route('credit')->user_id,
|
||||
],
|
||||
'amount' => 'sometimes|required|numeric|min:0',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -9,11 +9,13 @@ class OrderResource extends JsonApiResource
|
||||
public $attributes = [
|
||||
'id',
|
||||
'currency_code',
|
||||
'user_id',
|
||||
'updated_at',
|
||||
'created_at',
|
||||
];
|
||||
|
||||
public $relationships = [
|
||||
'services' => ServiceResource::class,
|
||||
'user' => UserResource::class,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ class ServiceResource extends JsonApiResource
|
||||
'id',
|
||||
'quantity',
|
||||
'price',
|
||||
'status',
|
||||
'currency_code',
|
||||
'expires_at',
|
||||
'updated_at',
|
||||
@@ -22,5 +23,6 @@ class ServiceResource extends JsonApiResource
|
||||
'order' => OrderResource::class,
|
||||
'product' => ProductResource::class,
|
||||
'invoices' => InvoiceResource::class,
|
||||
'property' => PropertyResource::class,
|
||||
];
|
||||
}
|
||||
|
||||
23
app/Http/Resources/TicketAttachmentResource.php
Normal file
23
app/Http/Resources/TicketAttachmentResource.php
Normal file
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Resources;
|
||||
|
||||
use TiMacDonald\JsonApi\JsonApiResource;
|
||||
|
||||
class TicketAttachmentResource extends JsonApiResource
|
||||
{
|
||||
public $attributes = [
|
||||
'id',
|
||||
'uuid',
|
||||
'filename',
|
||||
'path',
|
||||
'filesize',
|
||||
'mime_type',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
];
|
||||
|
||||
public $relationships = [
|
||||
'message' => TicketMessageResource::class,
|
||||
];
|
||||
}
|
||||
@@ -16,5 +16,6 @@ class TicketMessageResource extends JsonApiResource
|
||||
public $relationships = [
|
||||
'user' => UserResource::class,
|
||||
'ticket' => TicketResource::class,
|
||||
'attachments' => TicketAttachmentResource::class,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -11,6 +11,8 @@ class TicketResource extends JsonApiResource
|
||||
'subject',
|
||||
'status',
|
||||
'priority',
|
||||
'assigned_to',
|
||||
'user_id',
|
||||
'department',
|
||||
'updated_at',
|
||||
'created_at',
|
||||
|
||||
@@ -27,4 +27,17 @@ class UserResource extends JsonApiResource
|
||||
'credits' => CreditResource::class,
|
||||
'role' => RoleResource::class,
|
||||
];
|
||||
|
||||
public function toMeta($request): array
|
||||
{
|
||||
// Is properties loaded?
|
||||
$meta = [];
|
||||
if ($this->resource->relationLoaded('properties')) {
|
||||
$meta['properties'] = $this->resource->properties->mapWithKeys(function ($property) {
|
||||
return [$property->key => $property->value];
|
||||
});
|
||||
}
|
||||
|
||||
return $meta;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,9 @@ class CreateJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public $timeout = 60;
|
||||
public $timeout = 120;
|
||||
|
||||
public $tries = 1;
|
||||
|
||||
/**
|
||||
* Create a new job instance.
|
||||
@@ -35,6 +37,7 @@ class CreateJob implements ShouldQueue
|
||||
if ($e->getMessage() == 'No server assigned to this product') {
|
||||
return;
|
||||
}
|
||||
throw $e;
|
||||
}
|
||||
|
||||
if ($this->sendNotification && isset($data)) {
|
||||
|
||||
@@ -16,7 +16,9 @@ class SuspendJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public $timeout = 60;
|
||||
public $timeout = 120;
|
||||
|
||||
public $tries = 1;
|
||||
|
||||
/**
|
||||
* Create a new job instance.
|
||||
@@ -34,6 +36,7 @@ class SuspendJob implements ShouldQueue
|
||||
if ($e->getMessage() == 'No server assigned to this product') {
|
||||
return;
|
||||
}
|
||||
throw $e;
|
||||
}
|
||||
|
||||
if ($this->sendNotification && isset($data)) {
|
||||
|
||||
@@ -16,7 +16,9 @@ class TerminateJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public $timeout = 60;
|
||||
public $timeout = 120;
|
||||
|
||||
public $tries = 1;
|
||||
|
||||
/**
|
||||
* Create a new job instance.
|
||||
@@ -34,6 +36,7 @@ class TerminateJob implements ShouldQueue
|
||||
if ($e->getMessage() == 'No server assigned to this product') {
|
||||
return;
|
||||
}
|
||||
throw $e;
|
||||
}
|
||||
|
||||
if ($this->sendNotification && isset($data)) {
|
||||
|
||||
@@ -15,7 +15,9 @@ class UnsuspendJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public $timeout = 60;
|
||||
public $timeout = 120;
|
||||
|
||||
public $tries = 1;
|
||||
|
||||
/**
|
||||
* Create a new job instance.
|
||||
@@ -33,6 +35,7 @@ class UnsuspendJob implements ShouldQueue
|
||||
if ($e->getMessage() == 'No server assigned to this product') {
|
||||
return;
|
||||
}
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user