feat: add artisan command for manual package provisioning #79

Open
Charon wants to merge 1 commit from feat/artisan-provision-command into dev
2 changed files with 283 additions and 0 deletions

View file

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

View file

@ -0,0 +1,282 @@
<?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()
);
}
}