feat(stats): surface trials scheduled to cancel in experiment funnel (#665)
## 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.
This commit is contained in:
parent
ca2e5c09b3
commit
815ca6244c
|
|
@ -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,
|
||||
],
|
||||
[
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue