argument('workspace'); $packageCode = $this->argument('package'); $dryRun = $this->option('dry-run'); // Resolve workspace $workspace = is_numeric($workspaceIdentifier) ? Workspace::find($workspaceIdentifier) : Workspace::where('slug', $workspaceIdentifier)->first(); if (! $workspace) { $this->error("Workspace not found: {$workspaceIdentifier}"); return self::FAILURE; } // Resolve package $package = Package::where('code', $packageCode)->first(); if (! $package) { $this->error("Package not found: {$packageCode}"); $this->newLine(); $this->showAvailablePackages(); return self::FAILURE; } if (! $package->is_active) { $this->warn("Package '{$package->name}' ({$package->code}) is not active."); if (! $this->option('force') && ! $this->confirm('Do you want to provision it anyway?')) { return self::FAILURE; } } // Parse options $options = $this->buildOptions(); if ($options === null) { return self::FAILURE; } // Show what will happen $this->displayProvisioningSummary($workspace, $package, $options); // Check for existing base package conflict if ($package->is_base_package) { $existingBase = $workspace->workspacePackages() ->whereHas('package', fn ($q) => $q->where('is_base_package', true)) ->active() ->first(); if ($existingBase) { $this->warn("Workspace already has an active base package: {$existingBase->package->name} ({$existingBase->package->code})"); $this->line('The existing base package will be cancelled and replaced.'); $this->newLine(); } } if ($dryRun) { $this->info('[DRY RUN] No changes were made.'); return self::SUCCESS; } // Confirm unless --force if (! $this->option('force') && ! $this->confirm('Proceed with provisioning?')) { $this->info('Cancelled.'); return self::SUCCESS; } // Provision the package try { $workspacePackage = $this->entitlementService->provisionPackage( $workspace, $packageCode, $options ); } catch (ModelNotFoundException $e) { $this->error("Failed to provision package: {$e->getMessage()}"); return self::FAILURE; } catch (\Exception $e) { $this->error("Provisioning failed: {$e->getMessage()}"); return self::FAILURE; } // Output result $this->newLine(); $this->info('Package provisioned successfully.'); $this->newLine(); $this->table( ['Field', 'Value'], [ ['Workspace', "{$workspace->name} ({$workspace->slug})"], ['Package', "{$package->name} ({$package->code})"], ['Workspace Package ID', $workspacePackage->id], ['Status', $workspacePackage->status], ['Starts At', $workspacePackage->starts_at?->toDateTimeString() ?? 'Now'], ['Expires At', $workspacePackage->expires_at?->toDateTimeString() ?? 'Never'], ['Billing Anchor', $workspacePackage->billing_cycle_anchor?->toDateTimeString() ?? '-'], ['Source', $options['source'] ?? EntitlementLog::SOURCE_ADMIN], ] ); // Show features included $features = $package->features; if ($features->isNotEmpty()) { $this->newLine(); $this->info('Features included:'); $this->table( ['Feature', 'Code', 'Type', 'Limit'], $features->map(fn ($f) => [ $f->name, $f->code, $f->type, $f->pivot->limit_value ?? ($f->type === 'unlimited' ? 'Unlimited' : '-'), ])->toArray() ); } return self::SUCCESS; } /** * Build provisioning options from command arguments. */ protected function buildOptions(): ?array { $options = [ 'source' => $this->option('source') ?? EntitlementLog::SOURCE_ADMIN, ]; // Validate source $validSources = [ EntitlementLog::SOURCE_ADMIN, EntitlementLog::SOURCE_SYSTEM, EntitlementLog::SOURCE_BLESTA, EntitlementLog::SOURCE_COMMERCE, EntitlementLog::SOURCE_API, ]; if (! in_array($options['source'], $validSources, true)) { $this->error("Invalid source: {$options['source']}"); $this->line('Valid sources: '.implode(', ', $validSources)); return null; } if ($expiresAt = $this->option('expires-at')) { try { $options['expires_at'] = new \DateTimeImmutable($expiresAt); } catch (\Exception) { $this->error("Invalid expiry date: {$expiresAt}"); $this->line('Expected format: Y-m-d or Y-m-d H:i:s'); return null; } } if ($billingAnchor = $this->option('billing-anchor')) { try { $options['billing_cycle_anchor'] = new \DateTimeImmutable($billingAnchor); } catch (\Exception) { $this->error("Invalid billing anchor date: {$billingAnchor}"); $this->line('Expected format: Y-m-d'); return null; } } if ($blestaServiceId = $this->option('blesta-service-id')) { $options['blesta_service_id'] = $blestaServiceId; } $options['metadata'] = [ 'provisioned_via' => 'artisan', 'provisioned_at' => now()->toIso8601String(), ]; return $options; } /** * Display a summary of the provisioning action. */ protected function displayProvisioningSummary(Workspace $workspace, Package $package, array $options): void { $this->info('Provisioning Summary'); $this->newLine(); $rows = [ ['Workspace', "{$workspace->name} (ID: {$workspace->id}, slug: {$workspace->slug})"], ['Package', "{$package->name} ({$package->code})"], ['Package Type', $package->is_base_package ? 'Base package' : 'Add-on package'], ['Source', $options['source']], ['Expires At', isset($options['expires_at']) ? $options['expires_at']->format('Y-m-d H:i:s') : 'Never'], ]; if (isset($options['billing_cycle_anchor'])) { $rows[] = ['Billing Anchor', $options['billing_cycle_anchor']->format('Y-m-d H:i:s')]; } if (isset($options['blesta_service_id'])) { $rows[] = ['Blesta Service ID', $options['blesta_service_id']]; } $this->table(['Field', 'Value'], $rows); $this->newLine(); } /** * Show available packages when an invalid code is given. */ protected function showAvailablePackages(): void { $packages = Package::active()->ordered()->get(); if ($packages->isEmpty()) { $this->line('No active packages found.'); return; } $this->info('Available packages:'); $this->table( ['Code', 'Name', 'Type', 'Active'], $packages->map(fn (Package $p) => [ $p->code, $p->name, $p->is_base_package ? 'Base' : 'Add-on', $p->is_active ? 'Yes' : 'No', ])->toArray() ); } }