From 815ca6244c4eded808ee1a361883ac9bcbe04282 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADctor=20Falc=C3=B3n?= Date: Fri, 10 Jul 2026 19:22:58 +0200 Subject: [PATCH] feat(stats): surface trials scheduled to cancel in experiment funnel (#665) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Why In the experiment funnel report, variants with a trial (control, reduced) show `0` under Actv/Cncl/Rfnd while their signups are still mid-trial — so the table looked empty even though many of those trials have already been canceled by the user (Cashier keeps serving the trial until it ends, then simply doesn't charge). That "already lost, just not settled yet" cohort was invisible. On prod right now: 28 subscriptions in `trialing`, of which **10 are already scheduled to cancel** — a leading churn signal the report was hiding. ## What - `ExperimentFunnelCollector`: new per-variant `trialingCanceling` counter = `stripe_status === 'trialing'` **and** `ends_at !== null`. - Report table: two new columns — `Trl` (currently in trial, previously collected but never printed) and `TrlX` (of those, already scheduled to cancel and won't convert). Discord legend updated. - Test covering the new counter. `Trl`/`TrlX` are orthogonal leading indicators; the mature Net%/MRR/ARPU metrics are unchanged. ## Testing `php artisan test tests/Feature/SendExperimentFunnelReportCommandTest.php` — 9 passed. --- .../SendExperimentFunnelReportCommand.php | 8 +++++--- app/Services/Stats/ExperimentFunnelCollector.php | 5 ++++- .../SendExperimentFunnelReportCommandTest.php | 15 +++++++++++++++ 3 files changed, 24 insertions(+), 4 deletions(-) diff --git a/app/Console/Commands/SendExperimentFunnelReportCommand.php b/app/Console/Commands/SendExperimentFunnelReportCommand.php index 18bfa2c2..a065b8c5 100644 --- a/app/Console/Commands/SendExperimentFunnelReportCommand.php +++ b/app/Console/Commands/SendExperimentFunnelReportCommand.php @@ -62,17 +62,19 @@ class SendExperimentFunnelReportCommand extends Command private function tableLines(array $report): array { $revenue = $report['revenueAvailable']; - $lines = [sprintf('%-8s %5s %4s %5s %5s %5s %5s %8s %8s', 'Variant', 'Assg', 'Sub', 'Actv', 'Cncl', 'Rfnd', 'Net%', 'MRR', 'ARPU')]; + $lines = [sprintf('%-8s %5s %4s %5s %5s %5s %5s %5s %5s %8s %8s', 'Variant', 'Assg', 'Sub', 'Trl', 'TrlX', 'Actv', 'Cncl', 'Rfnd', 'Net%', 'MRR', 'ARPU')]; foreach (self::LABELS as $key => $label) { $row = $report['variants'][$key]; $mature = $row['assignedMature'] > 0; $lines[] = sprintf( - '%-8s %5d %4d %5d %5d %5d %5s %8s %8s', + '%-8s %5d %4d %5d %5d %5d %5d %5d %5s %8s %8s', $label, $row['assigned'], $row['subscribed'], + $row['trialing'], + $row['trialingCanceling'], $row['active'], $row['canceled'], $row['refunded'], @@ -115,7 +117,7 @@ class SendExperimentFunnelReportCommand extends Command ], [ 'name' => 'Legend', - 'value' => 'Assg = assigned · Sub = started a plan · Actv/Cncl = current status · Rfnd = self-service refunds (pay_now) · Net% = live, non-refunded subs ÷ assigned (mature users only) · MRR = monthly run-rate of those subs (yearly ÷ 12) · ARPU = MRR ÷ assigned · `pend`/`—` = no mature data yet.', + 'value' => 'Assg = assigned · Sub = started a plan · Trl = currently in trial · TrlX = of those, already scheduled to cancel at trial end (won\'t convert) · Actv/Cncl = current status · Rfnd = self-service refunds (pay_now) · Net% = live, non-refunded subs ÷ assigned (mature users only) · MRR = monthly run-rate of those subs (yearly ÷ 12) · ARPU = MRR ÷ assigned · `pend`/`—` = no mature data yet.', 'inline' => false, ], [ diff --git a/app/Services/Stats/ExperimentFunnelCollector.php b/app/Services/Stats/ExperimentFunnelCollector.php index f7973886..17d1794e 100644 --- a/app/Services/Stats/ExperimentFunnelCollector.php +++ b/app/Services/Stats/ExperimentFunnelCollector.php @@ -42,6 +42,7 @@ class ExperimentFunnelCollector * assigned: int, * subscribed: int, * trialing: int, + * trialingCanceling: int, * active: int, * canceled: int, * pastDue: int, @@ -102,6 +103,7 @@ class ExperimentFunnelCollector if ($subscription !== null) { $row['subscribed']++; $row['trialing'] += $status === 'trialing' ? 1 : 0; + $row['trialingCanceling'] += ($status === 'trialing' && $subscription->ends_at !== null) ? 1 : 0; $row['active'] += $status === 'active' ? 1 : 0; $row['canceled'] += $status === 'canceled' ? 1 : 0; $row['pastDue'] += $status === 'past_due' ? 1 : 0; @@ -143,7 +145,7 @@ class ExperimentFunnelCollector } /** - * @return array{assigned: int, subscribed: int, trialing: int, active: int, canceled: int, pastDue: int, refunded: int, assignedMature: int, activeMature: int, netActiveRate: ?float, mrrCents: int, arpuCents: ?int} + * @return array{assigned: int, subscribed: int, trialing: int, trialingCanceling: int, active: int, canceled: int, pastDue: int, refunded: int, assignedMature: int, activeMature: int, netActiveRate: ?float, mrrCents: int, arpuCents: ?int} */ private function emptyRow(): array { @@ -151,6 +153,7 @@ class ExperimentFunnelCollector 'assigned' => 0, 'subscribed' => 0, 'trialing' => 0, + 'trialingCanceling' => 0, 'active' => 0, 'canceled' => 0, 'pastDue' => 0, diff --git a/tests/Feature/SendExperimentFunnelReportCommandTest.php b/tests/Feature/SendExperimentFunnelReportCommandTest.php index 4654740a..e84c5c22 100644 --- a/tests/Feature/SendExperimentFunnelReportCommandTest.php +++ b/tests/Feature/SendExperimentFunnelReportCommandTest.php @@ -93,6 +93,21 @@ it('attributes users and their subscription status to the right variant', functi ->and($variants[SubscriptionExperiment::PAY_NOW]['activeMature'])->toBe(1); }); +it('counts trials already scheduled to cancel separately from trials that will convert', function () { + $signup = CarbonImmutable::parse('2026-06-28'); // still trialing under the test clock + + // A trial that will renew (no ends_at) and one the user already canceled (ends_at set). + experimentUser(SubscriptionExperiment::CONTROL, $signup, ['status' => 'trialing', 'at' => $signup]); + experimentUser(SubscriptionExperiment::CONTROL, $signup, [ + 'status' => 'trialing', 'at' => $signup, 'endsAt' => $signup->addDays(15), + ]); + + $control = app(ExperimentFunnelCollector::class)->collect()['variants'][SubscriptionExperiment::CONTROL]; + + expect($control['trialing'])->toBe(2) + ->and($control['trialingCanceling'])->toBe(1); +}); + it('computes MRR and ARPU from the net-active subscriptions', function () { $signup = CarbonImmutable::parse('2026-06-05');