feat(stats): add --no-discord to the remaining report commands (#607)

## What

Follow-up to the `--no-discord` flag added to `stats:experiment-funnel`.
Extends the same flag to the **other stats report commands that post to
Discord**, so any of them can be run on demand (e.g. inside the prod
container) and just printed to the console without posting:

```bash
php artisan stats:subscription-funnel --no-discord
php artisan stats:ai-cohort-report   --no-discord
php artisan stats:stuck-cohort-report --no-discord
php artisan stats:daily-report       --no-discord
```

## How

- The two **funnel** commands (`subscription-funnel`) already printed
their table to the console, so `--no-discord` just short-circuits the
`->send()`.
- The **cohort/daily** commands only ever built a Discord embed. A small
`App\Console\Commands\Concerns\RendersReportToConsole` trait renders an
embed (title / description / fields) as plain text, so `--no-discord`
prints a readable report to the console instead of posting.
- Default behaviour is unchanged: without the flag (and on the scheduled
weekly/daily runs) they post to Discord exactly as before.

## Tests

A `--no-discord` test added to each command (`Http::assertNothingSent()`
+ the command succeeds). Pint + Larastan + all four command suites
green.

> Note: the commands query the default DB connection, so for
**production** numbers run them inside the prod container (Coolify
terminal).
This commit is contained in:
Víctor Falcón 2026-06-29 15:32:31 +02:00 committed by GitHub
parent 1db2871398
commit 300756e553
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 137 additions and 9 deletions

View File

@ -0,0 +1,45 @@
<?php
namespace App\Console\Commands\Concerns;
use Illuminate\Console\Command;
/**
* Render Discord report embeds as plain console output, for the --no-discord
* flag on the stats:* report commands.
*
* @mixin Command
*/
trait RendersReportToConsole
{
/**
* @param array<int, array<string, mixed>> $embeds
*/
protected function printEmbeds(array $embeds): void
{
foreach ($embeds as $embed) {
if (! empty($embed['title'])) {
$this->newLine();
$this->line('<options=bold>'.$this->plainText($embed['title']).'</>');
}
if (! empty($embed['description'])) {
$this->line($this->plainText($embed['description']));
}
foreach ($embed['fields'] ?? [] as $field) {
$this->newLine();
$this->line('<options=bold>'.$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));
}
}

View File

@ -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.');

View File

@ -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.');

View File

@ -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.');

View File

@ -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');

View File

@ -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,

View File

@ -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' => []]);

View File

@ -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');

View File

@ -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();
});