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:
Víctor Falcón 2026-07-10 19:22:58 +02:00 committed by GitHub
parent ca2e5c09b3
commit 815ca6244c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 24 additions and 4 deletions

View File

@ -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,
],
[

View File

@ -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,

View File

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