Implement academy analytics, billing, and web stories updates
This commit is contained in:
288
app/Console/Commands/AcademyBillingHealthCommand.php
Normal file
288
app/Console/Commands/AcademyBillingHealthCommand.php
Normal file
@@ -0,0 +1,288 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user