From a08025d0d24634c07c58ffb48f7bed3529c359a8 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 24 Mar 2026 13:51:34 +0000 Subject: [PATCH] feat: add tenant:provision-package artisan command Create artisan command for manual package provisioning, replacing the need to use Tinker. The command accepts workspace ID/slug and package code, validates inputs, shows a summary, and confirms before provisioning. Supports --dry-run, --force, --expires-at, --billing-anchor, --blesta-service-id, and --source options. Fixes #32 Co-Authored-By: Claude Opus 4.6 (1M context) --- Boot.php | 1 + Console/Commands/ProvisionPackage.php | 282 ++++++++++++++++++++++++++ 2 files changed, 283 insertions(+) create mode 100644 Console/Commands/ProvisionPackage.php diff --git a/Boot.php b/Boot.php index c347fe0..ed60780 100644 --- a/Boot.php +++ b/Boot.php @@ -185,6 +185,7 @@ class Boot extends ServiceProvider $event->command(Console\Commands\ProcessAccountDeletions::class); $event->command(Console\Commands\CheckUsageAlerts::class); $event->command(Console\Commands\ResetBillingCycles::class); + $event->command(Console\Commands\ProvisionPackage::class); // Security migration commands $event->command(Console\Commands\EncryptTwoFactorSecrets::class); diff --git a/Console/Commands/ProvisionPackage.php b/Console/Commands/ProvisionPackage.php new file mode 100644 index 0000000..1000dce --- /dev/null +++ b/Console/Commands/ProvisionPackage.php @@ -0,0 +1,282 @@ +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() + ); + } +} -- 2.45.3