diff --git a/app/Console/Commands/Concerns/RendersReportToConsole.php b/app/Console/Commands/Concerns/RendersReportToConsole.php new file mode 100644 index 00000000..359f1ae1 --- /dev/null +++ b/app/Console/Commands/Concerns/RendersReportToConsole.php @@ -0,0 +1,45 @@ +> $embeds + */ + protected function printEmbeds(array $embeds): void + { + foreach ($embeds as $embed) { + if (! empty($embed['title'])) { + $this->newLine(); + $this->line(''.$this->plainText($embed['title']).''); + } + + if (! empty($embed['description'])) { + $this->line($this->plainText($embed['description'])); + } + + foreach ($embed['fields'] ?? [] as $field) { + $this->newLine(); + $this->line(''.$this->plainText($field['name']).''); + $this->line($this->plainText($field['value'])); + } + } + } + + /** + * Strip Discord markdown (code fences, bold) for readable console output. + */ + private function plainText(string $text): string + { + return trim(str_replace(['```', '**'], '', $text)); + } +} diff --git a/app/Console/Commands/SendAiCohortReportCommand.php b/app/Console/Commands/SendAiCohortReportCommand.php index 2dec83b9..dda26f0a 100644 --- a/app/Console/Commands/SendAiCohortReportCommand.php +++ b/app/Console/Commands/SendAiCohortReportCommand.php @@ -2,6 +2,7 @@ namespace App\Console\Commands; +use App\Console\Commands\Concerns\RendersReportToConsole; use App\Services\Ai\AiCohortReportCollector; use App\Services\Discord\DiscordWebhook; use Carbon\CarbonImmutable; @@ -9,7 +10,9 @@ use Illuminate\Console\Command; class SendAiCohortReportCommand extends Command { - protected $signature = 'stats:ai-cohort-report {--weeks= : Number of weekly cohorts to include}'; + use RendersReportToConsole; + + protected $signature = 'stats:ai-cohort-report {--weeks= : Number of weekly cohorts to include} {--no-discord : Print the report to the console only, without posting to Discord}'; protected $description = 'Post the weekly AI-suggestions cohort retention/conversion report to Discord'; @@ -23,11 +26,19 @@ class SendAiCohortReportCommand extends Command $weeks = $this->option('weeks') !== null ? (int) $this->option('weeks') : null; $report = $this->collector->collect($weeks); + $embed = $this->buildEmbed($report); + + if ($this->option('no-discord')) { + $this->printEmbeds([$embed]); + $this->info('Skipped Discord (--no-discord).'); + + return self::SUCCESS; + } $webhookUrl = config('services.discord.ai_cohort_webhook_url') ?: config('services.discord.webhook_url'); - (new DiscordWebhook($webhookUrl))->send('', [$this->buildEmbed($report)]); + (new DiscordWebhook($webhookUrl))->send('', [$embed]); $this->info('AI cohort report sent to Discord.'); diff --git a/app/Console/Commands/SendDailyStatsReportCommand.php b/app/Console/Commands/SendDailyStatsReportCommand.php index aa7a4eba..68fc4965 100644 --- a/app/Console/Commands/SendDailyStatsReportCommand.php +++ b/app/Console/Commands/SendDailyStatsReportCommand.php @@ -2,6 +2,7 @@ namespace App\Console\Commands; +use App\Console\Commands\Concerns\RendersReportToConsole; use App\Models\User; use App\Services\Discord\DiscordWebhook; use App\Services\Stripe\SubscriptionStatsCollector; @@ -11,7 +12,9 @@ use Stripe\Exception\ApiErrorException; class SendDailyStatsReportCommand extends Command { - protected $signature = 'stats:daily-report'; + use RendersReportToConsole; + + protected $signature = 'stats:daily-report {--no-discord : Print the report to the console only, without posting to Discord}'; protected $description = 'Post yesterday\'s user and Stripe subscription stats to the Discord admin channel'; @@ -45,9 +48,16 @@ class SendDailyStatsReportCommand extends Command ->where('created_at', '<', $todayStart->copy()->utc()) ->count(); - $this->discord->send('', [ - $this->buildEmbed($stats, $newUsers, $totalUsers, $yesterdayStart), - ]); + $embed = $this->buildEmbed($stats, $newUsers, $totalUsers, $yesterdayStart); + + if ($this->option('no-discord')) { + $this->printEmbeds([$embed]); + $this->info('Skipped Discord (--no-discord).'); + + return self::SUCCESS; + } + + $this->discord->send('', [$embed]); $this->info('Daily stats report sent to Discord.'); diff --git a/app/Console/Commands/SendStuckCohortReportCommand.php b/app/Console/Commands/SendStuckCohortReportCommand.php index 38905be3..7eb1ffed 100644 --- a/app/Console/Commands/SendStuckCohortReportCommand.php +++ b/app/Console/Commands/SendStuckCohortReportCommand.php @@ -2,6 +2,7 @@ namespace App\Console\Commands; +use App\Console\Commands\Concerns\RendersReportToConsole; use App\Models\StuckCohortSnapshot; use App\Services\Discord\DiscordWebhook; use App\Services\Stats\StuckCohortReportCollector; @@ -9,7 +10,9 @@ use Illuminate\Console\Command; class SendStuckCohortReportCommand extends Command { - protected $signature = 'stats:stuck-cohort-report'; + use RendersReportToConsole; + + protected $signature = 'stats:stuck-cohort-report {--no-discord : Print the report to the console only, without posting to Discord}'; protected $description = 'Post the weekly paywall stuck-cohort report (banked users without a valid subscription) to Discord'; @@ -21,11 +24,19 @@ class SendStuckCohortReportCommand extends Command public function handle(): int { $report = $this->collector->collect(); + $embed = $this->buildEmbed($report); + + if ($this->option('no-discord')) { + $this->printEmbeds([$embed]); + $this->info('Skipped Discord (--no-discord).'); + + return self::SUCCESS; + } $webhookUrl = config('services.discord.ai_cohort_webhook_url') ?: config('services.discord.webhook_url'); - (new DiscordWebhook($webhookUrl))->send('', [$this->buildEmbed($report)]); + (new DiscordWebhook($webhookUrl))->send('', [$embed]); $this->info('Stuck cohort report sent to Discord.'); diff --git a/app/Console/Commands/SendSubscriptionFunnelReportCommand.php b/app/Console/Commands/SendSubscriptionFunnelReportCommand.php index 7defd98c..e8541d9b 100644 --- a/app/Console/Commands/SendSubscriptionFunnelReportCommand.php +++ b/app/Console/Commands/SendSubscriptionFunnelReportCommand.php @@ -8,7 +8,7 @@ use Illuminate\Console\Command; class SendSubscriptionFunnelReportCommand extends Command { - protected $signature = 'stats:subscription-funnel {--weeks= : Number of weekly cohorts to include}'; + protected $signature = 'stats:subscription-funnel {--weeks= : Number of weekly cohorts to include} {--no-discord : Print the report to the console only, without posting to Discord}'; protected $description = 'Post the weekly registration -> subscription -> paid funnel to Discord'; @@ -27,6 +27,12 @@ class SendSubscriptionFunnelReportCommand extends Command $this->line($line); } + if ($this->option('no-discord')) { + $this->info('Skipped Discord (--no-discord).'); + + return self::SUCCESS; + } + $webhookUrl = config('services.discord.ai_cohort_webhook_url') ?: config('services.discord.webhook_url'); diff --git a/tests/Feature/SendAiCohortReportCommandTest.php b/tests/Feature/SendAiCohortReportCommandTest.php index 7ea5cc98..4f97cde4 100644 --- a/tests/Feature/SendAiCohortReportCommandTest.php +++ b/tests/Feature/SendAiCohortReportCommandTest.php @@ -211,6 +211,17 @@ it('posts the cohort report embed to the configured discord webhook', function ( }); }); +it('prints to the console without posting when --no-discord is set', function () { + config(['services.discord.ai_cohort_webhook_url' => 'https://discord.test/hook']); + Http::fake(['discord.test/*' => Http::response('', 204)]); + + cohortUser(referenceNow()->subWeeks(6), ['transactions' => 3, 'lastActiveAt' => referenceNow()->subWeeks(3)]); + + artisan('stats:ai-cohort-report', ['--no-discord' => true])->assertSuccessful(); + + Http::assertNothingSent(); +}); + it('falls back to the default discord webhook when no dedicated one is set', function () { config([ 'services.discord.ai_cohort_webhook_url' => null, diff --git a/tests/Feature/SendDailyStatsReportCommandTest.php b/tests/Feature/SendDailyStatsReportCommandTest.php index 6017fa41..953720c6 100644 --- a/tests/Feature/SendDailyStatsReportCommandTest.php +++ b/tests/Feature/SendDailyStatsReportCommandTest.php @@ -32,6 +32,20 @@ test('posts yesterday user counts and stripe stats to discord', function () { }); }); +test('prints to the console without posting when --no-discord is set', function () { + Http::fake(); + bindMockStripeClientForStats([ + 'active' => [makeStripeSubscription('eur', 1000, 'month')], + 'trialing' => [], + ]); + + User::factory()->create(['created_at' => Carbon::now('Europe/Madrid')->subDay()->setTime(12, 0)->utc()]); + + $this->artisan('stats:daily-report', ['--no-discord' => true])->assertSuccessful(); + + Http::assertNothingSent(); +}); + test('total reflects users at end of yesterday and excludes users created today', function () { Http::fake(); bindMockStripeClientForStats(['active' => [], 'trialing' => []]); diff --git a/tests/Feature/SendStuckCohortReportCommandTest.php b/tests/Feature/SendStuckCohortReportCommandTest.php index a16fcca9..0a30b412 100644 --- a/tests/Feature/SendStuckCohortReportCommandTest.php +++ b/tests/Feature/SendStuckCohortReportCommandTest.php @@ -132,6 +132,15 @@ it('upserts a single snapshot per day across runs', function () { expect(StuckCohortSnapshot::query()->whereDate('date', today())->count())->toBe(1); }); +it('prints to the console without posting when --no-discord is set', function () { + stuckUser(); + subscribedUser('active'); + + artisan('stats:stuck-cohort-report', ['--no-discord' => true])->assertSuccessful(); + + Http::assertNothingSent(); +}); + it('posts the stuck cohort embed to the configured discord webhook', function () { stuckUser(); subscribedUser('active'); diff --git a/tests/Feature/SendSubscriptionFunnelReportCommandTest.php b/tests/Feature/SendSubscriptionFunnelReportCommandTest.php index ed5d7d47..4e001cd1 100644 --- a/tests/Feature/SendSubscriptionFunnelReportCommandTest.php +++ b/tests/Feature/SendSubscriptionFunnelReportCommandTest.php @@ -142,3 +142,14 @@ it('posts the funnel embed to the configured discord webhook', function () { && str_contains($request['embeds'][0]['title'], 'Subscription Funnel'); }); }); + +it('prints to the console without posting when --no-discord is set', function () { + config(['services.discord.ai_cohort_webhook_url' => 'https://discord.test/hook']); + Http::fake(['discord.test/*' => Http::response('', 204)]); + + funnelUser(funnelNow()->subWeeks(10), ['status' => 'active', 'at' => funnelNow()->subWeeks(10)->addDays(2)]); + + artisan('stats:subscription-funnel', ['--no-discord' => true])->assertSuccessful(); + + Http::assertNothingSent(); +});