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');