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:
parent
d7bc4e6707
commit
756b4816a6
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue