diff --git a/app/Console/Commands/SendSubscriptionFunnelReportCommand.php b/app/Console/Commands/SendSubscriptionFunnelReportCommand.php new file mode 100644 index 00000000..7defd98c --- /dev/null +++ b/app/Console/Commands/SendSubscriptionFunnelReportCommand.php @@ -0,0 +1,130 @@ + 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>} $report + * @return list + */ + 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>} $report + * @return array + */ + 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)); + } +} diff --git a/app/Services/Stats/SubscriptionFunnelCollector.php b/app/Services/Stats/SubscriptionFunnelCollector.php new file mode 100644 index 00000000..2617dc68 --- /dev/null +++ b/app/Services/Stats/SubscriptionFunnelCollector.php @@ -0,0 +1,178 @@ + 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 + * } + */ + 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 + */ + 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> $rows + * @param list $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 $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; + } +} diff --git a/routes/console.php b/routes/console.php index 997b06c3..3f324e9a 100644 --- a/routes/console.php +++ b/routes/console.php @@ -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'); diff --git a/tests/Feature/SendSubscriptionFunnelReportCommandTest.php b/tests/Feature/SendSubscriptionFunnelReportCommandTest.php new file mode 100644 index 00000000..ed5d7d47 --- /dev/null +++ b/tests/Feature/SendSubscriptionFunnelReportCommandTest.php @@ -0,0 +1,144 @@ +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>} $report + * @return array + */ +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'); + }); +});