feat(stats): weekly paywall stuck-cohort report to Discord (#563)

## What

Adds a weekly `stats:stuck-cohort-report` command that measures the
percentage of users "stuck" on the paywall, compares it to the previous
week, and posts the result to Discord.

## Definition of "stuck"

A user with a non-deleted banking connection and **no** valid
subscription:

- has a banking connection (`bankingConnections()` — SoftDeletes already
excludes deleted)
- has no subscription with `stripe_status` in `active` / `trialing` /
`past_due`, nor a `canceled` one still in grace (`ends_at > now()`)

Built with Eloquent (`whereHas` / `whereDoesntHave`), no raw SQL.

## Metric

`stuck_pct = stuck / onboarded users` (`onboarded_at` not null — the
population that reached the paywall). The Discord message also shows the
raw counts, not just the percentage.

## Week-over-week

Each run snapshots `date`, `onboarded_count`, `stuck_count`, `stuck_pct`
into a new `stuck_cohort_snapshots` table (`updateOrCreate` per day,
idempotent), then compares against the most recent prior snapshot to
report the delta in percentage points and stuck count. First run has no
prior → reports current only.

## Discord

Reuses the `SendAiCohortReportCommand` pattern: posts an embed via
`DiscordWebhook` to `config('services.discord.ai_cohort_webhook_url')`
(fallback `webhook_url`). No direct `env()`.

## Schedule


`Schedule::command('stats:stuck-cohort-report')->weekly()->mondays()->at('09:00')->timezone('Europe/Madrid')`
in `routes/console.php`.

## Tests

7 tests (percentage calc across subscription statuses incl. canceled
in/out of grace, soft-deleted connection, non-onboarded excluded from
denominator, snapshot persistence, delta vs previous, single upsert per
day, Discord POST asserted via `Http::fake`). `pint` clean.
This commit is contained in:
Víctor Falcón 2026-06-19 16:12:51 +02:00 committed by GitHub
parent ce6bfc9c56
commit 57f8c93e28
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 424 additions and 0 deletions

View File

@ -0,0 +1,97 @@
<?php
namespace App\Console\Commands;
use App\Models\StuckCohortSnapshot;
use App\Services\Discord\DiscordWebhook;
use App\Services\Stats\StuckCohortReportCollector;
use Illuminate\Console\Command;
class SendStuckCohortReportCommand extends Command
{
protected $signature = 'stats:stuck-cohort-report';
protected $description = 'Post the weekly paywall stuck-cohort report (banked users without a valid subscription) to Discord';
public function __construct(private StuckCohortReportCollector $collector)
{
parent::__construct();
}
public function handle(): int
{
$report = $this->collector->collect();
$webhookUrl = config('services.discord.ai_cohort_webhook_url')
?: config('services.discord.webhook_url');
(new DiscordWebhook($webhookUrl))->send('', [$this->buildEmbed($report)]);
$this->info('Stuck cohort report sent to Discord.');
return self::SUCCESS;
}
/**
* @param array{snapshot: StuckCohortSnapshot, previous: ?StuckCohortSnapshot, pctDelta: ?float, stuckDelta: ?int} $report
* @return array<string, mixed>
*/
private function buildEmbed(array $report): array
{
$snapshot = $report['snapshot'];
$lines = [
sprintf('Stuck %d', $snapshot->stuck_count),
sprintf('Onboarded %d', $snapshot->onboarded_count),
sprintf('Stuck rate %s%%', $this->formatPct((float) $snapshot->stuck_pct)),
];
if ($report['previous'] !== null) {
$lines[] = '';
$lines[] = sprintf(
'vs %s: %s pp · %s stuck',
$report['previous']->date->format('d M'),
$this->formatSignedPct((float) $report['pctDelta']),
$this->formatSignedInt((int) $report['stuckDelta']),
);
} else {
$lines[] = '';
$lines[] = 'First snapshot — no previous week to compare.';
}
return [
'title' => '🪤 Paywall — Weekly Stuck Cohort',
'description' => "```\n".implode("\n", $lines)."\n```",
'color' => 0xED4245,
'fields' => [
[
'name' => 'Definition',
'value' => 'Stuck = onboarded users with a non-deleted banking connection but no valid subscription (active/trialing/past_due, or canceled but still within the grace period).',
'inline' => false,
],
[
'name' => 'Denominator',
'value' => 'Stuck rate = stuck / onboarded users (`onboarded_at` not null) — the population that has reached the paywall.',
'inline' => false,
],
],
];
}
private function formatPct(float $value): string
{
return rtrim(rtrim(number_format($value, 2, '.', ''), '0'), '.');
}
private function formatSignedPct(float $value): string
{
$sign = $value > 0 ? '+' : '';
return $sign.$this->formatPct($value);
}
private function formatSignedInt(int $value): string
{
return ($value > 0 ? '+' : '').$value;
}
}

View File

@ -0,0 +1,35 @@
<?php
namespace App\Models;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Model;
/**
* @property Carbon $date
* @property int $onboarded_count
* @property int $stuck_count
* @property float $stuck_pct
*/
class StuckCohortSnapshot extends Model
{
protected $fillable = [
'date',
'onboarded_count',
'stuck_count',
'stuck_pct',
];
/**
* @return array<string, string>
*/
protected function casts(): array
{
return [
'date' => 'date',
'onboarded_count' => 'integer',
'stuck_count' => 'integer',
'stuck_pct' => 'float',
];
}
}

View File

@ -0,0 +1,73 @@
<?php
namespace App\Services\Stats;
use App\Models\StuckCohortSnapshot;
use App\Models\User;
use Illuminate\Contracts\Database\Eloquent\Builder;
class StuckCohortReportCollector
{
/**
* Compute this week's stuck-cohort snapshot, persist it, and compare it
* against the most recent previous snapshot.
*
* A user is "stuck" when they have at least one non-deleted banking
* connection but no valid subscription, i.e. they connected a bank but
* never made it past the paywall. The percentage is measured against
* users who completed onboarding (`onboarded_at` not null), which is the
* population that has actually reached the paywall.
*
* @return array{
* snapshot: StuckCohortSnapshot,
* previous: ?StuckCohortSnapshot,
* pctDelta: ?float,
* stuckDelta: ?int,
* }
*/
public function collect(): array
{
$onboardedCount = User::query()
->whereNotNull('onboarded_at')
->count();
$stuckCount = User::query()
->whereNotNull('onboarded_at')
->whereHas('bankingConnections')
->whereDoesntHave('subscriptions', function (Builder $query): void {
$query->where(function (Builder $query): void {
$query->whereIn('stripe_status', ['active', 'trialing', 'past_due'])
->orWhere(function (Builder $query): void {
$query->where('stripe_status', 'canceled')
->where('ends_at', '>', now());
});
});
})
->count();
$stuckPct = $onboardedCount > 0
? round($stuckCount / $onboardedCount * 100, 2)
: 0.0;
$previous = StuckCohortSnapshot::query()
->whereDate('date', '<', today())
->orderByDesc('date')
->first();
$snapshot = StuckCohortSnapshot::query()->updateOrCreate(
['date' => today()],
[
'onboarded_count' => $onboardedCount,
'stuck_count' => $stuckCount,
'stuck_pct' => $stuckPct,
],
);
return [
'snapshot' => $snapshot,
'previous' => $previous,
'pctDelta' => $previous !== null ? round($stuckPct - (float) $previous->stuck_pct, 2) : null,
'stuckDelta' => $previous !== null ? $stuckCount - $previous->stuck_count : null,
];
}
}

View File

@ -0,0 +1,31 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('stuck_cohort_snapshots', function (Blueprint $table) {
$table->id();
$table->date('date')->unique();
$table->unsignedInteger('onboarded_count');
$table->unsignedInteger('stuck_count');
$table->decimal('stuck_pct', 5, 2);
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('stuck_cohort_snapshots');
}
};

View File

@ -14,3 +14,4 @@ Schedule::command('leads:send-re-invitations --force')->dailyAt('09:00');
Schedule::command('email:paywall-follow-up')->dailyAt('10:00')->timezone('Europe/Madrid');
Schedule::command('stats:daily-report')->dailyAt('09:00')->timezone('Europe/Madrid');
Schedule::command('stats:ai-cohort-report')->monthlyOn(1, '09:00')->timezone('Europe/Madrid');
Schedule::command('stats:stuck-cohort-report')->weekly()->mondays()->at('09:00')->timezone('Europe/Madrid');

View File

@ -0,0 +1,187 @@
<?php
use App\Models\BankingConnection;
use App\Models\StuckCohortSnapshot;
use App\Models\User;
use App\Services\Stats\StuckCohortReportCollector;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Str;
use function Pest\Laravel\artisan;
/**
* Create an onboarded user with a non-deleted banking connection but no valid
* subscription: the definition of a paywall-stuck user.
*/
function stuckUser(): User
{
$user = User::factory()->onboarded()->create();
BankingConnection::factory()->for($user)->create();
return $user;
}
/**
* Create an onboarded user with a banking connection and a subscription in the
* given status (defaults to a valid, active one).
*
* @param array<string, mixed> $attributes
*/
function subscribedUser(string $status = 'active', array $attributes = []): User
{
$user = User::factory()->onboarded()->create();
BankingConnection::factory()->for($user)->create();
$user->subscriptions()->create(array_merge([
'type' => 'default',
'stripe_id' => 'sub_'.Str::random(12),
'stripe_status' => $status,
'stripe_price' => 'price_test',
], $attributes));
return $user;
}
beforeEach(function () {
config(['services.discord.ai_cohort_webhook_url' => 'https://discord.test/hook']);
Http::fake(['discord.test/*' => Http::response('', 204)]);
});
it('counts onboarded banked users without a valid subscription as stuck', function () {
// Stuck: onboarded + bank + no valid subscription.
stuckUser();
stuckUser();
stuckUser();
// Not stuck: valid subscriptions in their various forms.
subscribedUser('active');
subscribedUser('trialing');
subscribedUser('past_due');
subscribedUser('canceled', ['ends_at' => now()->addWeek()]); // still in grace period
// Stuck: canceled subscription whose grace period already lapsed.
subscribedUser('canceled', ['ends_at' => now()->subDay()]);
// Excluded from denominator: never finished onboarding.
$notOnboarded = User::factory()->notOnboarded()->create();
BankingConnection::factory()->for($notOnboarded)->create();
// Not stuck: onboarded but never connected a bank.
User::factory()->onboarded()->create();
// Not stuck: soft-deleted connection does not count.
$deletedConnectionUser = User::factory()->onboarded()->create();
BankingConnection::factory()->for($deletedConnectionUser)->create()->delete();
$report = app(StuckCohortReportCollector::class)->collect();
// Stuck users: 3 plain + 1 lapsed-canceled = 4.
// Onboarded denominator: 4 stuck + 4 valid-sub + 1 no-bank + 1 deleted-conn = 10.
expect($report['snapshot']->stuck_count)->toBe(4)
->and($report['snapshot']->onboarded_count)->toBe(10)
->and((float) $report['snapshot']->stuck_pct)->toBe(40.0);
});
it('persists the snapshot and reports no delta on the first run', function () {
stuckUser();
subscribedUser('active');
$report = app(StuckCohortReportCollector::class)->collect();
expect(StuckCohortSnapshot::query()->count())->toBe(1)
->and($report['previous'])->toBeNull()
->and($report['pctDelta'])->toBeNull()
->and($report['stuckDelta'])->toBeNull();
$snapshot = StuckCohortSnapshot::query()->sole();
expect($snapshot->stuck_count)->toBe(1)
->and($snapshot->onboarded_count)->toBe(2)
->and((float) $snapshot->stuck_pct)->toBe(50.0);
});
it('reports the delta against the most recent previous snapshot', function () {
StuckCohortSnapshot::query()->create([
'date' => today()->subWeek(),
'onboarded_count' => 10,
'stuck_count' => 2,
'stuck_pct' => 20.0,
]);
// This week: 6 stuck out of 10 onboarded = 60%.
foreach (range(1, 6) as $ignored) {
stuckUser();
}
foreach (range(1, 4) as $ignored) {
subscribedUser('active');
}
$report = app(StuckCohortReportCollector::class)->collect();
expect($report['snapshot']->stuck_pct)->toBe(60.0)
->and($report['previous'])->not->toBeNull()
->and($report['pctDelta'])->toBe(40.0)
->and($report['stuckDelta'])->toBe(4);
});
it('upserts a single snapshot per day across runs', function () {
stuckUser();
app(StuckCohortReportCollector::class)->collect();
app(StuckCohortReportCollector::class)->collect();
expect(StuckCohortSnapshot::query()->whereDate('date', today())->count())->toBe(1);
});
it('posts the stuck cohort embed to the configured discord webhook', function () {
stuckUser();
subscribedUser('active');
artisan('stats:stuck-cohort-report')->assertSuccessful();
Http::assertSent(function ($request) {
return $request->url() === 'https://discord.test/hook'
&& isset($request['embeds'][0]['title'])
&& str_contains($request['embeds'][0]['title'], 'Stuck Cohort')
&& str_contains($request['embeds'][0]['description'], 'Stuck 1')
&& str_contains($request['embeds'][0]['description'], 'Onboarded 2')
&& str_contains($request['embeds'][0]['description'], 'Stuck rate 50%');
});
});
it('reports the week-over-week delta in the discord embed', function () {
StuckCohortSnapshot::query()->create([
'date' => today()->subWeek(),
'onboarded_count' => 4,
'stuck_count' => 1,
'stuck_pct' => 25.0,
]);
foreach (range(1, 2) as $ignored) {
stuckUser();
}
foreach (range(1, 2) as $ignored) {
subscribedUser('active');
}
artisan('stats:stuck-cohort-report')->assertSuccessful();
Http::assertSent(function ($request) {
$description = $request['embeds'][0]['description'] ?? '';
return str_contains($description, '+25 pp')
&& str_contains($description, '+1 stuck');
});
});
it('falls back to the default discord webhook when no dedicated one is set', function () {
config([
'services.discord.ai_cohort_webhook_url' => null,
'services.discord.webhook_url' => 'https://discord.test/default',
]);
stuckUser();
artisan('stats:stuck-cohort-report')->assertSuccessful();
Http::assertSent(fn ($request) => $request->url() === 'https://discord.test/default');
});