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) <noreply@anthropic.com>
282 lines
9.1 KiB
PHP
282 lines
9.1 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace Core\Tenant\Console\Commands;
|
|
|
|
use Core\Tenant\Models\EntitlementLog;
|
|
use Core\Tenant\Models\Package;
|
|
use Core\Tenant\Models\Workspace;
|
|
use Core\Tenant\Services\EntitlementService;
|
|
use Illuminate\Console\Command;
|
|
use Illuminate\Database\Eloquent\ModelNotFoundException;
|
|
|
|
/**
|
|
* Manually provision a package for a workspace.
|
|
*
|
|
* Replaces the need to use Tinker for manual package provisioning,
|
|
* providing proper validation, confirmation, and output.
|
|
*/
|
|
class ProvisionPackage extends Command
|
|
{
|
|
protected $signature = 'tenant:provision-package
|
|
{workspace : The workspace ID or slug}
|
|
{package : The package code to provision}
|
|
{--source=admin : Provisioning source (admin, system, blesta, commerce)}
|
|
{--expires-at= : Optional expiry date (Y-m-d or Y-m-d H:i:s)}
|
|
{--billing-anchor= : Billing cycle anchor date (Y-m-d, default: now)}
|
|
{--blesta-service-id= : Optional Blesta service ID}
|
|
{--dry-run : Show what would happen without making changes}
|
|
{--force : Skip confirmation prompt}';
|
|
|
|
protected $description = 'Provision a package for a workspace, granting access to all features in that package';
|
|
|
|
public function __construct(
|
|
protected EntitlementService $entitlementService
|
|
) {
|
|
parent::__construct();
|
|
}
|
|
|
|
public function handle(): int
|
|
{
|
|
$workspaceIdentifier = $this->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()
|
|
);
|
|
}
|
|
}
|