- Add highly optimized Dockerfile with Nginx and PHP-FPM 8.4 - Add docker-compose.yml configured with Redis and MariaDB 10.11 - Implement entrypoint.sh and supervisord.conf for background workers - Refactor legacy IMAP scripts into scheduled Artisan Commands - Secure app by removing old routes with hardcoded basic auth credentials - Configure email attachments to use Laravel Storage instead of insecure public/tmp
368 lines
15 KiB
PHP
368 lines
15 KiB
PHP
<?php
|
|
|
|
namespace App\Filament\Resources;
|
|
|
|
use App\Filament\Resources\PlanResource\Pages\CreatePlan;
|
|
use App\Filament\Resources\PlanResource\Pages\EditPlan;
|
|
use App\Filament\Resources\PlanResource\Pages\ListPlans;
|
|
use App\Models\Plan;
|
|
use App\Models\PlanTier;
|
|
use BackedEnum;
|
|
use Filament\Actions\BulkActionGroup;
|
|
use Filament\Actions\CreateAction;
|
|
use Filament\Actions\DeleteAction;
|
|
use Filament\Actions\DeleteBulkAction;
|
|
use Filament\Actions\EditAction;
|
|
use Filament\Actions\ViewAction;
|
|
use Filament\Forms\Components\Select;
|
|
use Filament\Forms\Components\Textarea;
|
|
use Filament\Forms\Components\TextInput;
|
|
use Filament\Forms\Components\Toggle;
|
|
use Filament\Resources\Resource;
|
|
use Filament\Schemas\Components\Grid;
|
|
use Filament\Schemas\Components\Section;
|
|
use Filament\Schemas\Schema;
|
|
use Filament\Support\Icons\Heroicon;
|
|
use Filament\Tables;
|
|
use Filament\Tables\Table;
|
|
use Illuminate\Database\Eloquent\Builder;
|
|
use Illuminate\Support\Facades\Log;
|
|
use UnitEnum;
|
|
|
|
class PlanResource extends Resource
|
|
{
|
|
protected static ?string $model = Plan::class;
|
|
|
|
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedCreditCard;
|
|
|
|
protected static string|UnitEnum|null $navigationGroup = 'Subscription Management';
|
|
|
|
public static function form(Schema $schema): Schema
|
|
{
|
|
return $schema
|
|
->components([
|
|
Grid::make(3)->schema([
|
|
TextInput::make('name')
|
|
->label('Plan Name')
|
|
->required()
|
|
->maxLength(255),
|
|
|
|
TextInput::make('price')
|
|
->label('Price')
|
|
->numeric()
|
|
->prefix('$')
|
|
->required(),
|
|
|
|
Select::make('billing_cycle_days')
|
|
->label('Billing Cycle')
|
|
->options([
|
|
1 => 'Daily',
|
|
7 => 'Weekly',
|
|
30 => 'Monthly',
|
|
60 => 'Bi-Monthly',
|
|
90 => 'Quarterly',
|
|
180 => 'Semi-Annual',
|
|
365 => 'Yearly',
|
|
])
|
|
->default(30)
|
|
->required(),
|
|
]),
|
|
|
|
Grid::make(2)->schema([
|
|
TextInput::make('product_id')
|
|
->label('Product ID')
|
|
->required()
|
|
->helperText('External product identifier'),
|
|
|
|
TextInput::make('pricing_id')
|
|
->label('Pricing ID')
|
|
->required()
|
|
->helperText('External pricing identifier'),
|
|
]),
|
|
|
|
Textarea::make('description')
|
|
->label('Description')
|
|
->rows(3)
|
|
->maxLength(500),
|
|
|
|
Grid::make(3)->schema([
|
|
Select::make('plan_tier_id')
|
|
->label('Plan Tier')
|
|
->options(PlanTier::pluck('name', 'id'))
|
|
->nullable()
|
|
->searchable()
|
|
->helperText('Optional tier classification'),
|
|
|
|
Toggle::make('is_active')
|
|
->label('Active')
|
|
->default(true)
|
|
->helperText('Plan is available for new subscriptions'),
|
|
|
|
TextInput::make('sort_order')
|
|
->label('Sort Order')
|
|
->numeric()
|
|
->default(0)
|
|
->helperText('Display order in pricing tables'),
|
|
]),
|
|
|
|
Section::make('Legacy Settings')
|
|
->description('Legacy payment provider settings (will be migrated to new system)')
|
|
->collapsible()
|
|
->schema([
|
|
Grid::make(3)->schema([
|
|
Toggle::make('monthly_billing')
|
|
->label('Monthly Billing (Legacy)')
|
|
->helperText('Legacy monthly billing flag'),
|
|
|
|
TextInput::make('mailbox_limit')
|
|
->label('Mailbox Limit')
|
|
->numeric()
|
|
->default(10)
|
|
->helperText('Maximum number of mailboxes'),
|
|
|
|
TextInput::make('shoppy_product_id')
|
|
->label('Shoppy Product ID')
|
|
->nullable(),
|
|
]),
|
|
]),
|
|
]);
|
|
}
|
|
|
|
public static function table(Table $table): Table
|
|
{
|
|
return $table
|
|
->columns([
|
|
Tables\Columns\TextColumn::make('name')
|
|
->label('Plan Name')
|
|
->searchable()
|
|
->sortable(),
|
|
|
|
Tables\Columns\TextColumn::make('planTier.name')
|
|
->label('Tier')
|
|
->badge()
|
|
->sortable()
|
|
->placeholder('No Tier'),
|
|
|
|
Tables\Columns\TextColumn::make('price')
|
|
->label('Price')
|
|
->money('USD')
|
|
->sortable(),
|
|
|
|
Tables\Columns\TextColumn::make('billing_cycle_days')
|
|
->label('Billing Cycle')
|
|
->badge()
|
|
->color(fn (int $state): string => match ($state) {
|
|
1 => 'success', // Daily - green
|
|
7 => 'info', // Weekly - blue
|
|
14 => 'primary', // Bi-weekly - primary blue
|
|
30 => 'warning', // Monthly - orange
|
|
60 => 'gray', // Bi-monthly - gray
|
|
90 => 'purple', // Quarterly - purple
|
|
180 => 'danger', // Semi-annually - red
|
|
365 => 'primary', // Annually - primary blue
|
|
730 => 'gray', // Biennially - gray
|
|
default => 'gray' // Custom cycles - gray
|
|
})
|
|
->formatStateUsing(fn (int $state): string => match ($state) {
|
|
1 => 'Daily',
|
|
7 => 'Weekly',
|
|
14 => 'Bi-weekly',
|
|
30 => 'Monthly',
|
|
60 => 'Bi-monthly',
|
|
90 => 'Quarterly',
|
|
180 => 'Semi-annually',
|
|
365 => 'Annually',
|
|
730 => 'Biennially',
|
|
default => "{$state} days"
|
|
})
|
|
->sortable(),
|
|
|
|
Tables\Columns\IconColumn::make('is_active')
|
|
->label('Active')
|
|
->boolean()
|
|
->trueColor('success')
|
|
->falseColor('danger'),
|
|
|
|
Tables\Columns\TextColumn::make('plan_providers_count')
|
|
->label('Providers')
|
|
->getStateUsing(fn ($record) => $record->plan_providers_count ?? 0)
|
|
->badge()
|
|
->color(fn ($record) => match (true) {
|
|
$record->plan_providers_count === 0 => 'danger',
|
|
$record->plan_providers_count === 1 => 'warning',
|
|
$record->plan_providers_count >= 3 => 'success',
|
|
default => 'info'
|
|
})
|
|
->sortable(),
|
|
|
|
Tables\Columns\TextColumn::make('plan_feature_limits_count')
|
|
->label('Features')
|
|
->getStateUsing(fn ($record) => $record->plan_feature_limits_count ?? 0)
|
|
->badge()
|
|
->color(fn ($record) => match (true) {
|
|
$record->plan_feature_limits_count === 0 => 'danger',
|
|
$record->plan_feature_limits_count >= 5 => 'success',
|
|
$record->plan_feature_limits_count >= 3 => 'info',
|
|
default => 'warning'
|
|
})
|
|
->sortable(),
|
|
|
|
Tables\Columns\TextColumn::make('sort_order')
|
|
->label('Order')
|
|
->sortable()
|
|
->alignCenter(),
|
|
])
|
|
->filters([
|
|
Tables\Filters\SelectFilter::make('plan_tier_id')
|
|
->label('Tier')
|
|
->options(PlanTier::pluck('name', 'id'))
|
|
->searchable(),
|
|
|
|
Tables\Filters\TernaryFilter::make('is_active')
|
|
->label('Active Status')
|
|
->placeholder('All plans')
|
|
->trueLabel('Active only')
|
|
->falseLabel('Inactive only'),
|
|
|
|
Tables\Filters\SelectFilter::make('billing_cycle_days')
|
|
->label('Billing Cycle')
|
|
->options([
|
|
1 => 'Daily',
|
|
7 => 'Weekly',
|
|
30 => 'Monthly',
|
|
60 => 'Bi-Monthly',
|
|
90 => 'Quarterly',
|
|
180 => 'Semi-Annual',
|
|
365 => 'Yearly',
|
|
]),
|
|
|
|
Tables\Filters\Filter::make('has_providers')
|
|
->label('Has Payment Providers')
|
|
->query(fn (Builder $query): Builder => $query->whereHas('planProviders'))
|
|
->toggle(),
|
|
])
|
|
->recordActions([
|
|
ViewAction::make(),
|
|
EditAction::make(),
|
|
DeleteAction::make()
|
|
->requiresConfirmation()
|
|
->before(function (DeleteAction $action, Plan $record) {
|
|
// Prevent deletion if plan has any subscriptions (active, cancelled, etc.)
|
|
if ($record->subscriptions()->exists()) {
|
|
Log::warning('Attempted to delete plan with existing subscriptions', [
|
|
'plan_id' => $record->id,
|
|
'plan_name' => $record->name,
|
|
'subscription_count' => $record->subscriptions()->count(),
|
|
]);
|
|
|
|
// Show warning notification
|
|
\Filament\Notifications\Notification::make()
|
|
->warning()
|
|
->title('Cannot Delete Plan')
|
|
->body("Plan '{$record->name}' has {$record->subscriptions()->count()} subscription(s). Please cancel or remove all subscriptions first.")
|
|
->persistent()
|
|
->send();
|
|
|
|
// Halt the deletion process
|
|
$action->halt();
|
|
}
|
|
})
|
|
->action(function (Plan $record) {
|
|
// This action will only run if not halted
|
|
$record->delete();
|
|
|
|
// Show success notification
|
|
\Filament\Notifications\Notification::make()
|
|
->success()
|
|
->title('Plan Deleted')
|
|
->body("Plan '{$record->name}' has been deleted successfully.")
|
|
->send();
|
|
}),
|
|
])
|
|
->bulkActions([
|
|
BulkActionGroup::make([
|
|
DeleteBulkAction::make()
|
|
->requiresConfirmation()
|
|
->before(function (DeleteBulkAction $action, $records) {
|
|
foreach ($records as $record) {
|
|
if ($record->subscriptions()->exists()) {
|
|
Log::warning('Attempted to bulk delete plan with existing subscriptions', [
|
|
'plan_id' => $record->id,
|
|
'plan_name' => $record->name,
|
|
'subscription_count' => $record->subscriptions()->count(),
|
|
]);
|
|
|
|
\Filament\Notifications\Notification::make()
|
|
->warning()
|
|
->title('Cannot Delete Plans')
|
|
->body("Plan '{$record->name}' has {$record->subscriptions()->count()} subscription(s). Please cancel or remove all subscriptions first.")
|
|
->persistent()
|
|
->send();
|
|
|
|
// Halt the bulk deletion process
|
|
$action->halt();
|
|
|
|
return;
|
|
}
|
|
}
|
|
})
|
|
->action(function ($records) {
|
|
foreach ($records as $record) {
|
|
$record->delete();
|
|
}
|
|
|
|
\Filament\Notifications\Notification::make()
|
|
->success()
|
|
->title('Plans Deleted')
|
|
->body(count($records).' plan(s) have been deleted successfully.')
|
|
->send();
|
|
}),
|
|
]),
|
|
])
|
|
->emptyStateActions([
|
|
CreateAction::make(),
|
|
])
|
|
->defaultSort('sort_order', 'asc')
|
|
->groups([
|
|
Tables\Grouping\Group::make('planTier.name')
|
|
->label('Tier')
|
|
->collapsible(),
|
|
]);
|
|
}
|
|
|
|
public static function getEloquentQuery(): Builder
|
|
{
|
|
return parent::getEloquentQuery()
|
|
->withCount([
|
|
'planProviders',
|
|
'planFeatureLimits',
|
|
]);
|
|
}
|
|
|
|
public static function getRelations(): array
|
|
{
|
|
return [
|
|
//
|
|
];
|
|
}
|
|
|
|
public static function getPages(): array
|
|
{
|
|
return [
|
|
'index' => ListPlans::route('/'),
|
|
'create' => CreatePlan::route('/create'),
|
|
'edit' => EditPlan::route('/{record}/edit'),
|
|
];
|
|
}
|
|
|
|
public static function getNavigationBadge(): ?string
|
|
{
|
|
return static::getModel()::active()->count();
|
|
}
|
|
|
|
public static function getNavigationBadgeColor(): ?string
|
|
{
|
|
return 'success';
|
|
}
|
|
}
|