diff --git a/website/app/Console/Commands/RetryProvisioningCommand.php b/website/app/Console/Commands/RetryProvisioningCommand.php new file mode 100644 index 0000000..dfec7ba --- /dev/null +++ b/website/app/Console/Commands/RetryProvisioningCommand.php @@ -0,0 +1,132 @@ +option('max-attempts'); + + $failedServices = Service::query() + ->where('status', 'failed') + ->where('created_at', '>=', now()->subDays(7)) + ->with(['subscription', 'plan']) + ->get(); + + if ($failedServices->isEmpty()) { + $this->info('No failed services found to retry.'); + + return self::SUCCESS; + } + + $this->info("Found {$failedServices->count()} failed service(s) to evaluate."); + + $retried = 0; + $skipped = 0; + $succeeded = 0; + $failed = 0; + + foreach ($failedServices as $service) { + $failedAttempts = ProvisioningLog::query() + ->where('service_id', $service->id) + ->where('action', 'provision') + ->where('status', 'failed') + ->count(); + + if ($failedAttempts >= $maxAttempts) { + $this->warn("Service #{$service->id}: skipped (already at {$failedAttempts}/{$maxAttempts} attempts)."); + + if ($failedAttempts === $maxAttempts) { + ProvisioningFailed::dispatch( + $service, + 'Maximum retry attempts reached', + $failedAttempts, + true, + ); + } + + $skipped++; + + continue; + } + + if (! $service->subscription) { + $this->warn("Service #{$service->id}: skipped (no associated subscription)."); + $skipped++; + + continue; + } + + $attemptNumber = $failedAttempts + 1; + $this->info("Service #{$service->id}: retrying provisioning (attempt {$attemptNumber}/{$maxAttempts})..."); + + try { + $provisioningService = $provisioningFactory->make($service->service_type); + + DB::transaction(function () use ($service) { + $service->delete(); + }); + + $newService = $provisioningService->provision($service->subscription); + + $this->info("Service #{$newService->id}: provisioned successfully."); + Log::info("Provisioning retry succeeded for replaced service #{$service->id}", [ + 'old_service_id' => $service->id, + 'new_service_id' => $newService->id, + 'service_type' => $service->service_type, + 'attempt' => $attemptNumber, + ]); + + $retried++; + $succeeded++; + } catch (\Throwable $e) { + $this->error("Service #{$service->id}: provisioning failed — {$e->getMessage()}"); + Log::error("Provisioning retry failed for service #{$service->id}", [ + 'service_id' => $service->id, + 'service_type' => $service->service_type, + 'attempt' => $attemptNumber, + 'error' => $e->getMessage(), + ]); + + $isMaxReached = $attemptNumber >= $maxAttempts; + + $retryService = Service::query() + ->where('subscription_id', $service->subscription_id) + ->where('status', 'failed') + ->latest() + ->first(); + + ProvisioningFailed::dispatch( + $retryService ?? $service, + $e->getMessage(), + $attemptNumber, + $isMaxReached, + ); + + $retried++; + $failed++; + } + } + + $this->newLine(); + $this->info("Retry summary: {$retried} retried, {$succeeded} succeeded, {$failed} failed, {$skipped} skipped."); + + return self::SUCCESS; + } +} diff --git a/website/app/Events/ProvisioningFailed.php b/website/app/Events/ProvisioningFailed.php new file mode 100644 index 0000000..1129666 --- /dev/null +++ b/website/app/Events/ProvisioningFailed.php @@ -0,0 +1,21 @@ +service; + + Log::warning("Provisioning failed for service #{$service->id}", [ + 'service_id' => $service->id, + 'user_id' => $service->user_id, + 'service_type' => $service->service_type, + 'platform' => $service->platform, + 'error' => $event->errorMessage, + 'attempt' => $event->attemptNumber, + 'max_attempts_reached' => $event->maxAttemptsReached, + ]); + + if ($event->maxAttemptsReached) { + Log::error("Provisioning permanently failed for service #{$service->id} after {$event->attemptNumber} attempts", [ + 'service_id' => $service->id, + 'user_id' => $service->user_id, + 'service_type' => $service->service_type, + 'platform' => $service->platform, + 'error' => $event->errorMessage, + ]); + } + } +} diff --git a/website/routes/console.php b/website/routes/console.php index b80ba4b..41d0d0c 100644 --- a/website/routes/console.php +++ b/website/routes/console.php @@ -10,3 +10,4 @@ Artisan::command('inspire', function () { Schedule::command('billing:process-dunning')->daily()->at('06:00'); Schedule::command('tickets:process-emails')->everyTwoMinutes()->withoutOverlapping(); +Schedule::command('provisioning:retry')->everyThirtyMinutes();