From 57f8c93e2818ad53abcd8d1ad656aeb1f1edda4d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADctor=20Falc=C3=B3n?= Date: Fri, 19 Jun 2026 16:12:51 +0200 Subject: [PATCH] feat(stats): weekly paywall stuck-cohort report to Discord (#563) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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. --- .../Commands/SendStuckCohortReportCommand.php | 97 +++++++++ app/Models/StuckCohortSnapshot.php | 35 ++++ .../Stats/StuckCohortReportCollector.php | 73 +++++++ ...54_create_stuck_cohort_snapshots_table.php | 31 +++ routes/console.php | 1 + .../SendStuckCohortReportCommandTest.php | 187 ++++++++++++++++++ 6 files changed, 424 insertions(+) create mode 100644 app/Console/Commands/SendStuckCohortReportCommand.php create mode 100644 app/Models/StuckCohortSnapshot.php create mode 100644 app/Services/Stats/StuckCohortReportCollector.php create mode 100644 database/migrations/2026_06_19_134854_create_stuck_cohort_snapshots_table.php create mode 100644 tests/Feature/SendStuckCohortReportCommandTest.php diff --git a/app/Console/Commands/SendStuckCohortReportCommand.php b/app/Console/Commands/SendStuckCohortReportCommand.php new file mode 100644 index 00000000..38905be3 --- /dev/null +++ b/app/Console/Commands/SendStuckCohortReportCommand.php @@ -0,0 +1,97 @@ +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 + */ + 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; + } +} diff --git a/app/Models/StuckCohortSnapshot.php b/app/Models/StuckCohortSnapshot.php new file mode 100644 index 00000000..065268ac --- /dev/null +++ b/app/Models/StuckCohortSnapshot.php @@ -0,0 +1,35 @@ + + */ + protected function casts(): array + { + return [ + 'date' => 'date', + 'onboarded_count' => 'integer', + 'stuck_count' => 'integer', + 'stuck_pct' => 'float', + ]; + } +} diff --git a/app/Services/Stats/StuckCohortReportCollector.php b/app/Services/Stats/StuckCohortReportCollector.php new file mode 100644 index 00000000..c09d5097 --- /dev/null +++ b/app/Services/Stats/StuckCohortReportCollector.php @@ -0,0 +1,73 @@ +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, + ]; + } +} diff --git a/database/migrations/2026_06_19_134854_create_stuck_cohort_snapshots_table.php b/database/migrations/2026_06_19_134854_create_stuck_cohort_snapshots_table.php new file mode 100644 index 00000000..575451ac --- /dev/null +++ b/database/migrations/2026_06_19_134854_create_stuck_cohort_snapshots_table.php @@ -0,0 +1,31 @@ +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'); + } +}; diff --git a/routes/console.php b/routes/console.php index b60912a0..461e4de0 100644 --- a/routes/console.php +++ b/routes/console.php @@ -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'); diff --git a/tests/Feature/SendStuckCohortReportCommandTest.php b/tests/Feature/SendStuckCohortReportCommandTest.php new file mode 100644 index 00000000..a16fcca9 --- /dev/null +++ b/tests/Feature/SendStuckCohortReportCommandTest.php @@ -0,0 +1,187 @@ +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 $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'); +});