feat(stats): add weekly subscription funnel report (#599)

## What

Adds a weekly **registration → subscription → paid** funnel so we can
baseline subscription conversion and, later, measure A/B tests against
it.

- `stats:subscription-funnel` artisan command — prints the cohort table
and posts it to Discord (same webhook as the other weekly reports).
- `SubscriptionFunnelCollector` — builds the age-normalized weekly
cohort series.
- Scheduled weekly, Mondays 09:15, next to the existing cohort reports.

## How the funnel is defined

Every stage is measured from each user's **own signup**, so weekly
cohorts are compared at the same age regardless of the calendar.

- **Registered** — signups that week.
- **Subscribed** — started a `default` plan ≤30d after signup (entered
checkout/trial).
- **Paid** — that plan billed past the 15d trial: currently `active`, or
`canceled` only after outliving the trial. `trial_ends_at` is cleared on
cancel, so subscription lifespan is the reliable "did it ever bill"
signal.

Both stages are bounded by the same subscription-creation window, which
guarantees the funnel invariant **registered ≥ subscribed ≥ paid** (a
test enforces it — I caught and fixed a window bug where `paid` could
exceed `subscribed`). Cohorts too young to score show `pend`; signup
surges (launch/marketing waves) are flagged with .

## Current baseline (prod, 2026-06-27)

| Stage | Count | Rate |
|---|---|---|
| Registered | 1,289 | — |
| Subscribed | 158 | 12.3% of registered |
| Trial resolved | 128 | conversion denominator |
| Paid after trial | 91 | **71%** of resolved · 7.1% of registered |
| Currently active | 75 | 82% of payers retained |

⚠️ ~940 of the 1,289 signups arrived in the last ~5 weeks (a
late-May/June surge); their trials haven't fully resolved, so
cohort-level conversion for that wave isn't measurable yet. The 71% is
status-based — a clean cohort baseline lands in ~4–6 weeks.

## Tests

5 Pest tests covering cohort counting, the attribution window, the
funnel invariant, maturity gating, and Discord delivery. All green, Pint
clean.
This commit is contained in:
Víctor Falcón 2026-06-27 15:40:11 +02:00 committed by GitHub
parent d7bc4e6707
commit 756b4816a6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 453 additions and 0 deletions

View File

@ -0,0 +1,130 @@
<?php
namespace App\Console\Commands;
use App\Services\Discord\DiscordWebhook;
use App\Services\Stats\SubscriptionFunnelCollector;
use Illuminate\Console\Command;
class SendSubscriptionFunnelReportCommand extends Command
{
protected $signature = 'stats:subscription-funnel {--weeks= : Number of weekly cohorts to include}';
protected $description = 'Post the weekly registration -> subscription -> paid funnel to Discord';
public function __construct(private SubscriptionFunnelCollector $collector)
{
parent::__construct();
}
public function handle(): int
{
$weeks = $this->option('weeks') !== null ? (int) $this->option('weeks') : null;
$report = $this->collector->collect($weeks);
foreach ($this->tableLines($report) as $line) {
$this->line($line);
}
$webhookUrl = config('services.discord.ai_cohort_webhook_url')
?: config('services.discord.webhook_url');
(new DiscordWebhook($webhookUrl))->send('', [$this->buildEmbed($report)]);
$this->info('Subscription funnel report sent to Discord.');
return self::SUCCESS;
}
/**
* @param array{trialDays: int, weeks: list<array<string, mixed>>} $report
* @return list<string>
*/
private function tableLines(array $report): array
{
$lines = [sprintf('%-9s %5s %5s %5s %5s %5s %5s', 'Week', 'Reg', 'Sub', 'Sub%', 'Paid', 'Pd%', 'T2P')];
foreach ($report['weeks'] as $row) {
$lines[] = sprintf(
'%-9s %5d %5d %5s %5d %5s %5s%s',
$row['week'],
$row['registered'],
$row['subscribed'],
$this->rateCell($row['subscribedRate'], $row['subscribedMature'], $row['registered']),
$row['paid'],
$this->rateCell($row['paidRate'], $row['paidMature'], $row['registered']),
$this->rateCell($row['trialToPaidRate'], $row['paidMature'], $row['subscribed']),
$row['surge'] ? ' ⚡' : '',
);
}
return $lines;
}
/**
* @param array{trialDays: int, weeks: list<array<string, mixed>>} $report
* @return array<string, mixed>
*/
private function buildEmbed(array $report): array
{
$mature = array_values(array_filter($report['weeks'], fn (array $row): bool => $row['paidMature']));
$registered = array_sum(array_column($mature, 'registered'));
$subscribed = array_sum(array_column($mature, 'subscribed'));
$paid = array_sum(array_column($mature, 'paid'));
$totals = $registered > 0
? sprintf(
"Registered %d\nSubscribed %d (%s%%)\nPaid %d (%s%% of reg · %s%% of subs)",
$registered,
$subscribed,
$this->pct($subscribed / $registered),
$paid,
$this->pct($paid / $registered),
$subscribed > 0 ? $this->pct($paid / $subscribed) : '—',
)
: 'No mature cohorts yet.';
return [
'title' => '💸 Subscription Funnel — Weekly Cohorts',
'description' => "```\n".implode("\n", $this->tableLines($report))."\n```",
'color' => 0x57F287,
'fields' => [
[
'name' => 'Mature cohorts (baseline)',
'value' => "```\n".$totals."\n```",
'inline' => false,
],
[
'name' => 'Legend',
'value' => 'Reg = signups · Sub = started a plan ≤30d after signup · Paid = that plan billed past the '.$report['trialDays'].'d trial (active, or canceled only after billing) · Sub%/Pd% of signups · T2P = paid ÷ subscribed · `pend` = cohort too young to score · ⚡ = signup surge',
'inline' => false,
],
[
'name' => '⚠️ Directional only',
'value' => 'Cohorts compared at equal age. Surge weeks (⚡, e.g. launch/marketing) differ in acquisition channel and are not controlled — compare organic weeks like-for-like. This is the pre-A/B baseline, not a randomised test.',
'inline' => false,
],
],
];
}
private function rateCell(?float $rate, bool $mature, int $denominator): string
{
if ($denominator === 0) {
return '—';
}
if (! $mature || $rate === null) {
return 'pend';
}
return $this->pct($rate).'%';
}
private function pct(float $rate): string
{
return (string) ((int) round($rate * 100));
}
}

View File

@ -0,0 +1,178 @@
<?php
namespace App\Services\Stats;
use App\Models\User;
use Carbon\CarbonImmutable;
class SubscriptionFunnelCollector
{
private const SUBSCRIBE_WINDOW_DAYS = 30;
private const PAID_SETTLE_BUFFER_DAYS = 5;
private const DEFAULT_WEEKS = 26;
private const SURGE_MULTIPLIER = 2.5;
/**
* Build the weekly registration -> subscription -> paid funnel.
*
* Every stage is measured from each user's own signup so weekly cohorts are
* compared at the same age regardless of the calendar. "Subscribed" means a
* default subscription was started within the window; "paid" means that
* subscription converted past the trial (currently active, or canceled only
* after outliving the trial i.e. it billed at least once). By bounding both
* stages with the same subscription-creation window, paid is always a subset
* of subscribed and the funnel invariant registered >= subscribed >= paid holds.
*
* @return array{
* trialDays: int,
* weeks: list<array{
* week: string,
* weekStart: CarbonImmutable,
* registered: int,
* subscribed: int,
* subscribedRate: ?float,
* paid: int,
* paidRate: ?float,
* trialToPaidRate: ?float,
* surge: bool,
* subscribedMature: bool,
* paidMature: bool,
* }>
* }
*/
public function collect(?int $weeks = null): array
{
$weeks = max(1, $weeks ?? self::DEFAULT_WEEKS);
$trialDays = (int) config('subscriptions.plans.monthly.trial_days', 15);
$now = CarbonImmutable::now('UTC');
$windowStart = $now->startOfWeek(CarbonImmutable::MONDAY)->subWeeks($weeks - 1);
$aggregates = $this->aggregateUsers($windowStart, $trialDays);
$rows = [];
$registeredCounts = [];
for ($i = 0; $i < $weeks; $i++) {
$weekStart = $windowStart->addWeeks($i);
$weekEnd = $weekStart->endOfWeek(CarbonImmutable::SUNDAY);
$key = (int) $weekStart->format('oW');
$agg = $aggregates[$key] ?? ['registered' => 0, 'subscribed' => 0, 'paid' => 0];
$registered = $agg['registered'];
$subscribed = $agg['subscribed'];
$paid = $agg['paid'];
$subscribedMature = $weekEnd->addDays(self::SUBSCRIBE_WINDOW_DAYS)->lessThanOrEqualTo($now);
$paidMature = $weekEnd->addDays(self::SUBSCRIBE_WINDOW_DAYS + $trialDays + self::PAID_SETTLE_BUFFER_DAYS)->lessThanOrEqualTo($now);
$registeredCounts[] = $registered;
$rows[$i] = [
'week' => $weekStart->format('o-\WW'),
'weekStart' => $weekStart,
'registered' => $registered,
'subscribed' => $subscribed,
'subscribedRate' => $subscribedMature && $registered > 0 ? $subscribed / $registered : null,
'paid' => $paid,
'paidRate' => $paidMature && $registered > 0 ? $paid / $registered : null,
'trialToPaidRate' => $paidMature && $subscribed > 0 ? $paid / $subscribed : null,
'surge' => false,
'subscribedMature' => $subscribedMature,
'paidMature' => $paidMature,
];
}
$this->flagSurges($rows, $registeredCounts);
return [
'trialDays' => $trialDays,
'weeks' => array_values($rows),
];
}
/**
* Aggregate per-user funnel flags keyed by ISO year-week of signup.
*
* @return array<int, array{registered: int, subscribed: int, paid: int}>
*/
private function aggregateUsers(CarbonImmutable $windowStart, int $trialDays): array
{
$excluded = (array) config('ai_suggestions.report.excluded_emails', []);
$subWindow = self::SUBSCRIBE_WINDOW_DAYS;
$rows = User::query()
->when($excluded !== [], fn ($query) => $query->whereNotIn('email', $excluded))
->where('users.created_at', '>=', $windowStart)
->selectRaw('YEARWEEK(users.created_at, 3) as yearweek')
->selectRaw("EXISTS(SELECT 1 FROM subscriptions s WHERE s.user_id = users.id AND s.type = 'default' AND s.created_at <= DATE_ADD(users.created_at, INTERVAL {$subWindow} DAY)) as subscribed")
->selectRaw("EXISTS(SELECT 1 FROM subscriptions s WHERE s.user_id = users.id AND s.type = 'default' AND s.created_at <= DATE_ADD(users.created_at, INTERVAL {$subWindow} DAY) AND (s.stripe_status = 'active' OR (s.stripe_status = 'canceled' AND s.ends_at IS NOT NULL AND TIMESTAMPDIFF(DAY, s.created_at, s.ends_at) > {$trialDays}))) as paid")
->toBase()
->get();
$aggregates = [];
foreach ($rows as $row) {
$key = (int) $row->yearweek;
if (! isset($aggregates[$key])) {
$aggregates[$key] = ['registered' => 0, 'subscribed' => 0, 'paid' => 0];
}
$aggregates[$key]['registered']++;
$aggregates[$key]['subscribed'] += (int) $row->subscribed;
$aggregates[$key]['paid'] += (int) $row->paid;
}
return $aggregates;
}
/**
* Flag weeks whose signup volume is an outlier (a launch/marketing spike) so
* a non-representative acquisition wave can't be read as an organic trend.
*
* @param array<int, array<string, mixed>> $rows
* @param list<int> $registeredCounts
*/
private function flagSurges(array &$rows, array $registeredCounts): void
{
$nonZero = array_values(array_filter($registeredCounts, fn (int $count): bool => $count > 0));
$median = $this->median($nonZero);
if ($median <= 0.0) {
return;
}
foreach ($rows as $index => $row) {
if ($row['registered'] > self::SURGE_MULTIPLIER * $median) {
$rows[$index]['surge'] = true;
}
}
}
/**
* @param list<int> $values
*/
private function median(array $values): float
{
sort($values);
$count = count($values);
if ($count === 0) {
return 0.0;
}
$middle = intdiv($count, 2);
if ($count % 2 === 1) {
return (float) $values[$middle];
}
return ($values[$middle - 1] + $values[$middle]) / 2;
}
}

View File

@ -16,3 +16,4 @@ Schedule::command('email:ai-consent-follow-up')->dailyAt('10:15')->timezone('Eur
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');
Schedule::command('stats:subscription-funnel')->weekly()->mondays()->at('09:15')->timezone('Europe/Madrid');

View File

@ -0,0 +1,144 @@
<?php
use App\Models\User;
use App\Services\Stats\SubscriptionFunnelCollector;
use Carbon\CarbonImmutable;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Str;
use function Pest\Laravel\artisan;
function funnelNow(): CarbonImmutable
{
return CarbonImmutable::create(2026, 6, 17, 12, 0, 0, 'UTC');
}
/**
* Create a user anchored to a signup, optionally with a default subscription.
*
* @param array{status: string, at: CarbonImmutable, endsAt?: CarbonImmutable}|null $subscription
*/
function funnelUser(CarbonImmutable $signup, ?array $subscription = null): User
{
$user = User::factory()->create([
'email' => fake()->unique()->safeEmail(),
'created_at' => $signup,
]);
if ($subscription !== null) {
$user->subscriptions()->create([
'type' => 'default',
'stripe_id' => 'sub_'.Str::random(12),
'stripe_status' => $subscription['status'],
'stripe_price' => 'price_test',
'created_at' => $subscription['at'],
'ends_at' => $subscription['endsAt'] ?? null,
]);
}
return $user;
}
/**
* @param array{weeks: list<array<string, mixed>>} $report
* @return array<string, mixed>
*/
function funnelRow(array $report, CarbonImmutable $signup): array
{
$label = $signup->startOfWeek(CarbonImmutable::MONDAY)->format('o-\WW');
foreach ($report['weeks'] as $row) {
if ($row['week'] === $label) {
return $row;
}
}
throw new RuntimeException("No cohort row found for week {$label}");
}
beforeEach(function () {
Carbon::setTestNow(funnelNow());
config(['ai_suggestions.report.excluded_emails' => []]);
});
it('counts registrations, subscriptions and paid conversions per signup week', function () {
$signup = funnelNow()->subWeeks(10); // old enough to be paid-mature
funnelUser($signup); // registered only
funnelUser($signup, ['status' => 'trialing', 'at' => $signup->addDays(2)]); // subscribed, not paid
funnelUser($signup, ['status' => 'active', 'at' => $signup->addDays(3)]); // subscribed + paid
// Canceled but lived past the 15d trial -> billed at least once -> paid.
funnelUser($signup, ['status' => 'canceled', 'at' => $signup->addDays(3), 'endsAt' => $signup->addDays(40)]);
// Canceled inside the trial window -> never billed -> not paid.
funnelUser($signup, ['status' => 'canceled', 'at' => $signup->addDays(3), 'endsAt' => $signup->addDays(10)]);
$row = funnelRow(app(SubscriptionFunnelCollector::class)->collect(), $signup);
expect($row['registered'])->toBe(5)
->and($row['subscribed'])->toBe(4)
->and($row['paid'])->toBe(2)
->and($row['paidMature'])->toBeTrue()
->and($row['subscribedRate'])->toBe(4 / 5)
->and($row['paidRate'])->toBe(2 / 5)
->and($row['trialToPaidRate'])->toBe(2 / 4);
});
it('ignores subscriptions started after the attribution window', function () {
$signup = funnelNow()->subWeeks(10);
funnelUser($signup, ['status' => 'active', 'at' => $signup->addDays(40)]); // beyond 30d window
$row = funnelRow(app(SubscriptionFunnelCollector::class)->collect(), $signup);
expect($row['registered'])->toBe(1)
->and($row['subscribed'])->toBe(0)
->and($row['paid'])->toBe(0);
});
it('keeps the funnel invariant: registered >= subscribed >= paid', function () {
$signup = funnelNow()->subWeeks(10);
foreach ($report = app(SubscriptionFunnelCollector::class)->collect()['weeks'] as $row) {
expect($row['registered'])->toBeGreaterThanOrEqual($row['subscribed'])
->and($row['subscribed'])->toBeGreaterThanOrEqual($row['paid']);
}
expect($report)->not->toBeEmpty();
});
it('marks young cohorts as not yet mature', function () {
$recent = funnelNow()->subDays(3); // current week
$midAged = funnelNow()->subWeeks(5); // past the 30d subscribe window, inside the paid window
funnelUser($recent);
funnelUser($midAged, ['status' => 'active', 'at' => $midAged->addDays(2)]);
$report = app(SubscriptionFunnelCollector::class)->collect();
$recentRow = funnelRow($report, $recent);
expect($recentRow['subscribedMature'])->toBeFalse()
->and($recentRow['subscribedRate'])->toBeNull()
->and($recentRow['paidRate'])->toBeNull();
$midRow = funnelRow($report, $midAged);
expect($midRow['subscribedMature'])->toBeTrue()
->and($midRow['subscribedRate'])->not->toBeNull()
->and($midRow['paidMature'])->toBeFalse()
->and($midRow['paidRate'])->toBeNull();
});
it('posts the funnel embed to the configured discord webhook', 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')->assertSuccessful();
Http::assertSent(function ($request) {
return $request->url() === 'https://discord.test/hook'
&& isset($request['embeds'][0]['title'])
&& str_contains($request['embeds'][0]['title'], 'Subscription Funnel');
});
});