Files
SkinbaseNova/app/Console/Commands/AcademyBillingHealthCommand.php

288 lines
12 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Services\Academy\AcademyBillingPlanService;
use Illuminate\Console\Command;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\Facades\Schema;
final class AcademyBillingHealthCommand extends Command
{
protected $signature = 'academy:billing-health
{--json : Output machine-readable JSON}
{--strict : Exit non-zero when blocking issues are found}';
protected $description = 'Inspect Academy Stripe billing deployment readiness, config completeness, and Cashier route wiring';
public function __construct(
private readonly AcademyBillingPlanService $plans,
) {
parent::__construct();
}
public function handle(): int
{
$report = $this->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<string, mixed>
*/
private function buildReport(): array
{
$stripeKey = (string) config('cashier.key', '');
$stripeSecret = (string) config('cashier.secret', env('STRIPE_SECRET', ''));
$webhookSecret = (string) 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<string, mixed> $report
*/
private function exitCodeFor(array $report): int
{
if ((bool) $this->option('strict') && $report['status'] === 'BLOCKED') {
return self::FAILURE;
}
return self::SUCCESS;
}
}