305 lines
12 KiB
PHP
305 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 = $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<string, mixed> $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) : '';
|
|
}
|
|
} |