This commit is contained in:
Muhammad Tamir
2025-11-14 10:59:24 +07:00
parent 85c03cef82
commit b3933b9960
505 changed files with 13811 additions and 2341 deletions

View File

@@ -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

View File

@@ -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 @@
[![GitHub release (latest by date)](https://img.shields.io/github/v/release/paymenter/paymenter)](https://github.com/Paymenter/paymenter/releases)
<br>
<br>
[![Discord](https://img.shields.io/discord/882318291014651924?logo=discord&labelColor=white&color=5865f2)](https://discord.gg/xB4UUT3XQg)
[![Discord](https://img.shields.io/discord/882318291014651924?logo=discord&labelColor=white&color=5865f2)](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).

View 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');
}
}

View File

@@ -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');
}
}

View File

@@ -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,
];
}

View File

@@ -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(),
]);
}
}

View File

@@ -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')

View File

@@ -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');
}
}

View File

@@ -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

View File

@@ -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');
}
}

View File

@@ -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([

View File

@@ -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()

View 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'),
];
}
}

View File

@@ -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;
}

View File

@@ -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(),
];
}
}

View File

@@ -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(),
];
}
}

View File

@@ -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');
}

View File

@@ -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

View File

@@ -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(

View File

@@ -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,
];
}
}

View File

@@ -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';
}

View File

@@ -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(),

View File

@@ -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'),

View File

@@ -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,
]);
}
}

View File

@@ -31,6 +31,8 @@ class EditUser extends EditRecord
'tickets',
'credits',
'orders',
'billingAgreements',
'properties',
]),
];
}

View File

@@ -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'),
]);
}
}

View File

@@ -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
{

View File

@@ -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
{

View 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']),
]);
}
}

View 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()),
];
}
}

View 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()),
];
}
}

View 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';
}
}

View File

@@ -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');

View File

@@ -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,

View File

@@ -12,5 +12,6 @@ class DisabledIf
public function __construct(
public string $setting,
public bool $default = false,
public bool $reverse = false
) {}
}

View File

@@ -15,5 +15,6 @@ class ExtensionMeta
public string $version,
public string $author,
public string $url = '',
public string $icon = '',
) {}
}

View File

@@ -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');
}
}

View File

@@ -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() {}
}

View File

@@ -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');
}
}

View File

@@ -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;
}
}

View File

@@ -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]);
}
}

View 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);
}
}
}

View 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;
}
}

View File

@@ -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,
};
}
}

View File

@@ -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;
}
}
}

View File

@@ -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.');
}

View File

@@ -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.');
}
}

View 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());
}
}
}
}

View File

@@ -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());

View File

@@ -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();
}
}
}

View 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);
});
}
}

View File

@@ -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;

View File

@@ -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,

View 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()]
);
}
}

View File

@@ -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

View File

@@ -0,0 +1,10 @@
<?php
namespace App\Enums;
enum InvoiceTransactionStatus: string
{
case Processing = 'processing';
case Succeeded = 'succeeded';
case Failed = 'failed';
}

View 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';
}

View 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) {}
}

View 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) {}
}

View 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) {}
}

View 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) {}
}

View 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) {}
}

View 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) {}
}

View 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) {}
}

View 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) {}
}

View 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) {}
}

View 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) {}
}

View 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) {}
}

View 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) {}
}

View 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;
}
}

View 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) {}
}

View 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) {}
}

View 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) {}
}

View 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) {}
}

View 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) {}
}

View 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) {}
}

View File

@@ -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

View 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;
}
}

View File

@@ -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);
}
}
}

View File

@@ -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);
}
}

View 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();
}
}

View File

@@ -19,6 +19,7 @@ class OrderController extends ApiController
{
protected const INCLUDES = [
'services',
'user',
];
/**

View File

@@ -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

View File

@@ -19,6 +19,7 @@ class TicketMessageController extends ApiController
protected const INCLUDES = [
'user',
'ticket',
'attachments',
];
/**

View 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();
}
}

View 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',
];
}
}

View 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';
}

View 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';
}

View 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';
}

View 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',
];
}
}

View File

@@ -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,
];
}

View File

@@ -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,
];
}

View 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,
];
}

View File

@@ -16,5 +16,6 @@ class TicketMessageResource extends JsonApiResource
public $relationships = [
'user' => UserResource::class,
'ticket' => TicketResource::class,
'attachments' => TicketAttachmentResource::class,
];
}

View File

@@ -11,6 +11,8 @@ class TicketResource extends JsonApiResource
'subject',
'status',
'priority',
'assigned_to',
'user_id',
'department',
'updated_at',
'created_at',

View File

@@ -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;
}
}

View File

@@ -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)) {

View File

@@ -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)) {

View File

@@ -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)) {

View File

@@ -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