buildReport(); if ((bool) $this->option('json')) { $this->line((string) json_encode($report, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); return $this->exitCodeFor($report); } $this->line('Academy Billing Health Check'); $this->line('============================'); $this->newLine(); $this->line(sprintf('Environment: %s', $report['environment'])); $this->line(sprintf('App URL: %s', $report['app_url'] ?? 'unset')); $this->line(sprintf('Academy enabled: %s', $report['academy_enabled'] ? 'yes' : 'no')); $this->line(sprintf('Academy billing enabled: %s', $report['academy_billing_enabled'] ? 'yes' : 'no')); $this->line(sprintf('Subscription name: %s', $report['subscription_name'])); $this->line(sprintf('Cashier path: %s', $report['cashier_path'])); $this->line(sprintf('Cashier webhook route: %s', $report['routes']['cashier_webhook']['present'] ? ($report['routes']['cashier_webhook']['url'] ?? 'present') : 'missing')); $this->line(sprintf('Academy pricing route: %s', $report['routes']['academy_pricing']['present'] ? ($report['routes']['academy_pricing']['url'] ?? 'present') : 'missing')); $this->line(sprintf('Academy billing account route: %s', $report['routes']['academy_billing_account']['present'] ? ($report['routes']['academy_billing_account']['url'] ?? 'present') : 'missing')); $this->line(sprintf('Stripe key configured: %s', $report['stripe']['publishable_key_configured'] ? 'yes' : 'no')); $this->line(sprintf('Stripe secret configured: %s', $report['stripe']['secret_key_configured'] ? 'yes' : 'no')); $this->line(sprintf('Webhook secret configured: %s', $report['stripe']['webhook_secret_configured'] ? 'yes' : 'no')); $this->line(sprintf('Cashier currency: %s', $report['stripe']['currency'] ?: 'unset')); $this->line(sprintf('Cashier locale: %s', $report['stripe']['currency_locale'] ?: 'unset')); $this->line(sprintf('Configured plans: %d', $report['configured_plan_count'])); $this->line(sprintf('Plans missing Stripe price IDs: %d', count($report['missing_plan_keys']))); $this->line(sprintf('Billing tables present: %s', $report['tables']['subscriptions'] && $report['tables']['subscription_items'] && $report['tables']['academy_billing_events'] ? 'yes' : 'no')); $this->line(sprintf('User billing columns present: %s', $report['users_billing_columns_present'] ? 'yes' : 'no')); $this->newLine(); foreach ($report['blockers'] as $blocker) { $this->error(sprintf('BLOCKER: %s', $blocker)); } foreach ($report['warnings'] as $warning) { $this->warn(sprintf('WARNING: %s', $warning)); } if ($report['plan_summaries'] !== []) { $this->newLine(); $this->line('Plans'); $this->line('-----'); foreach ($report['plan_summaries'] as $plan) { $this->line(sprintf( '%s: tier=%s interval=%s price_id=%s', $plan['key'], $plan['tier'], $plan['interval'], $plan['configured'] ? 'configured' : 'missing' )); } } $this->newLine(); $this->info(sprintf('Status: %s', $report['status'])); return $this->exitCodeFor($report); } /** * @return array */ private function buildReport(): array { $stripeKey = $this->configuredString(config('cashier.key')); $stripeSecret = $this->firstConfiguredString(config('cashier.secret'), env('STRIPE_SECRET')); $webhookSecret = $this->firstConfiguredString(config('cashier.webhook.secret'), env('STRIPE_WEBHOOK_SECRET')); $currency = trim((string) config('cashier.currency', env('CASHIER_CURRENCY', ''))); $currencyLocale = trim((string) config('cashier.currency_locale', env('CASHIER_CURRENCY_LOCALE', ''))); $academyEnabled = (bool) config('academy.enabled', true); $billingEnabled = $this->plans->enabled(); $missingPlanKeys = $this->plans->missingPriceIds(); $routes = [ 'cashier_webhook' => $this->routeStatus('cashier.webhook'), 'academy_pricing' => $this->routeStatus('academy.pricing'), 'academy_billing_account' => $this->routeStatus('academy.billing.account'), 'academy_billing_portal' => $this->routeStatus('academy.billing.portal'), 'admin_academy_billing' => $this->routeStatus('admin.academy.billing'), ]; $tables = [ 'users' => Schema::hasTable('users'), 'subscriptions' => Schema::hasTable('subscriptions'), 'subscription_items' => Schema::hasTable('subscription_items'), 'academy_billing_events' => Schema::hasTable('academy_billing_events'), ]; $userBillingColumns = [ 'stripe_id' => $tables['users'] && Schema::hasColumn('users', 'stripe_id'), 'pm_type' => $tables['users'] && Schema::hasColumn('users', 'pm_type'), 'pm_last_four' => $tables['users'] && Schema::hasColumn('users', 'pm_last_four'), 'trial_ends_at' => $tables['users'] && Schema::hasColumn('users', 'trial_ends_at'), ]; $planSummaries = collect(array_keys($this->plans->plans())) ->map(function (string $key): array { $plan = $this->plans->plan($key); return [ 'key' => $key, 'tier' => (string) ($plan['tier'] ?? 'free'), 'interval' => (string) ($plan['interval'] ?? 'monthly'), 'configured' => (bool) ($plan['configured'] ?? false), ]; }) ->values() ->all(); $blockers = []; $warnings = []; if (! $academyEnabled) { $warnings[] = 'SKINBASE_ACADEMY_ENABLED is disabled, so billing cannot be reached by users.'; } if (! $billingEnabled) { $warnings[] = 'ACADEMY_BILLING_ENABLED is disabled. Checkout routes will stay unavailable until rollout is enabled.'; } if (! $this->isConfiguredSecret($stripeKey, 'pk_')) { $blockers[] = 'STRIPE_KEY is missing or still using a placeholder value.'; } if (! $this->isConfiguredSecret($stripeSecret, 'sk_')) { $blockers[] = 'STRIPE_SECRET is missing or still using a placeholder value.'; } if (! $this->isConfiguredSecret($webhookSecret, 'whsec_')) { $blockers[] = 'STRIPE_WEBHOOK_SECRET is missing or still using a placeholder value.'; } if ($currency === '') { $blockers[] = 'CASHIER_CURRENCY is not configured.'; } if ($currencyLocale === '') { $warnings[] = 'CASHIER_CURRENCY_LOCALE is not configured.'; } if ($missingPlanKeys !== []) { $blockers[] = 'Stripe price IDs are missing for: '.implode(', ', $missingPlanKeys).'.'; } if (! $routes['cashier_webhook']['present']) { $blockers[] = 'Cashier webhook route is missing; Stripe cannot sync subscriptions.'; } if (! $routes['academy_pricing']['present']) { $blockers[] = 'Academy pricing route is missing.'; } if (! $routes['academy_billing_account']['present']) { $blockers[] = 'Academy billing account route is missing.'; } foreach ($tables as $table => $present) { if (! $present) { $blockers[] = sprintf('Required billing table %s is missing.', $table); } } foreach ($userBillingColumns as $column => $present) { if (! $present) { $blockers[] = sprintf('Required users.%s billing column is missing.', $column); } } if (! $routes['admin_academy_billing']['present']) { $warnings[] = 'Moderation Academy billing overview route is missing.'; } if (Arr::where($planSummaries, fn (array $plan): bool => $plan['configured'] === false) === []) { $warnings[] = 'All configured Academy plans have Stripe price IDs. Verify they are live-mode IDs before production rollout.'; } $invalidPlanKeys = collect(array_keys($this->plans->plans())) ->filter(function (string $key): bool { $plan = $this->plans->plan($key); return $plan !== null && ($plan['configured'] ?? false) && ! ($plan['price_id_valid'] ?? false); }) ->values() ->all(); if ($invalidPlanKeys !== []) { $blockers[] = 'Stripe price IDs are malformed for: '.implode(', ', $invalidPlanKeys).'. Use real price object IDs that start with price_.'; } $status = $blockers !== [] ? 'BLOCKED' : ($warnings !== [] ? 'WARNING' : 'OK'); return [ 'environment' => app()->environment(), 'app_url' => config('app.url'), 'academy_enabled' => $academyEnabled, 'academy_billing_enabled' => $billingEnabled, 'subscription_name' => $this->plans->subscriptionName(), 'cashier_path' => (string) config('cashier.path', 'stripe'), 'stripe' => [ 'publishable_key_configured' => $this->isConfiguredSecret($stripeKey, 'pk_'), 'secret_key_configured' => $this->isConfiguredSecret($stripeSecret, 'sk_'), 'webhook_secret_configured' => $this->isConfiguredSecret($webhookSecret, 'whsec_'), 'currency' => $currency, 'currency_locale' => $currencyLocale, ], 'configured_plan_count' => count($planSummaries), 'missing_plan_keys' => $missingPlanKeys, 'invalid_plan_keys' => $invalidPlanKeys, 'plan_summaries' => $planSummaries, 'routes' => $routes, 'tables' => $tables, 'user_billing_columns' => $userBillingColumns, 'users_billing_columns_present' => ! in_array(false, $userBillingColumns, true), 'blockers' => array_values(array_unique($blockers)), 'warnings' => array_values(array_unique($warnings)), 'status' => $status, ]; } /** * @return array{present: bool, url: string|null} */ private function routeStatus(string $name): array { if (! Route::has($name)) { return [ 'present' => false, 'url' => null, ]; } return [ 'present' => true, 'url' => route($name), ]; } private function isConfiguredSecret(string $value, string $expectedPrefix): bool { $value = trim($value); if ($value === '' || ! str_starts_with($value, $expectedPrefix)) { return false; } return ! str_contains(strtolower($value), 'xxx'); } /** * @param array $report */ private function exitCodeFor(array $report): int { if ((bool) $this->option('strict') && $report['status'] === 'BLOCKED') { return self::FAILURE; } return self::SUCCESS; } private function firstConfiguredString(mixed ...$values): string { foreach ($values as $value) { $value = $this->configuredString($value); if ($value !== '') { return $value; } } return ''; } private function configuredString(mixed $value): string { return is_string($value) ? trim($value) : ''; } }