fix(stats): count only revenue-less users as connection burn

Burn incremented for every matured user who was not currently net-active,
which wrongly booked users who paid and later churned as connect-and-leave
leak (and mislabelled it 'never converted'). Restrict burn to users who never
earned net revenue: no subscription at all, or paid then refunded. A paid-then-
canceled user converted, so their connection cost is no longer burn.
This commit is contained in:
Víctor Falcón 2026-07-15 07:46:13 +02:00
parent af03a179af
commit a6024d35ee
3 changed files with 26 additions and 2 deletions

View File

@ -127,7 +127,7 @@ class SendExperimentFunnelReportCommand extends Command
[
'name' => 'Legend',
'value' => sprintf(
'Assg = signups · Actd = activated (connected a bank or enabled AI = cost triggered) · Card = completed checkout (card on file) · MatU = matured assigned (cohort old enough to score for this variant) · Paid = live non-refunded subs among MatU · Conv%% = Paid ÷ MatU (net conversion, always ≤100%%, comparable across variants) · ARPU = MRR ÷ MatU (revenue per matured user) · MRR = monthly run-rate of paid subs (yearly ÷ 12) · Cost = est. connection cost of MatU (%s/connection) · Burn = connection cost of matured users not currently paying · CM = MRR Cost · `pend`/`—` = no matured data yet.',
'Assg = signups · Actd = activated (connected a bank or enabled AI = cost triggered) · Card = completed checkout (card on file) · MatU = matured assigned (cohort old enough to score for this variant) · Paid = live non-refunded subs among MatU · Conv%% = Paid ÷ MatU (net conversion, always ≤100%%, comparable across variants) · ARPU = MRR ÷ MatU (revenue per matured user) · MRR = monthly run-rate of paid subs (yearly ÷ 12) · Cost = est. connection cost of MatU (%s/connection) · Burn = connection cost of matured users who never earned net revenue (connected a bank but never paid, or paid then refunded) · CM = MRR Cost · `pend`/`—` = no matured data yet.',
$this->money($report['costPerConnectionCents'], $report['currency']),
),
'inline' => false,

View File

@ -174,7 +174,12 @@ class ExperimentFunnelCollector
}
$row['mrrCents'] += (int) ($monthlyEquiv[$priceId] ?? 0);
} else {
} elseif ($subscription === null || $subscription->refunded_at !== null) {
// Burn = connections of matured users who never earned
// net revenue: connected a bank but never carded, or
// paid and got refunded. A user who paid and later
// churned (canceled, not refunded) did convert, so
// their connection cost is not burn.
$row['wastedCostCents'] += $connectionCostCents;
}
}

View File

@ -248,6 +248,25 @@ it('reports a matured-cohort conversion capped at 100% and prints the matured de
->assertSuccessful();
});
it('excludes churned payers from burn but keeps refunds as burn', function () {
$signup = CarbonImmutable::parse('2026-06-05');
// Paid then canceled (not refunded): converted, so its cost is NOT burn.
experimentUser(SubscriptionExperiment::CONTROL, $signup, [
'status' => 'canceled', 'at' => $signup, 'endsAt' => $signup->addDays(20),
], connections: 2);
// Paid then refunded: zero net revenue, so its cost IS burn.
experimentUser(SubscriptionExperiment::CONTROL, $signup, [
'status' => 'canceled', 'at' => $signup, 'endsAt' => $signup->addDay(), 'refundedAt' => $signup->addDay(),
], connections: 3);
$control = app(ExperimentFunnelCollector::class)->collect()['variants'][SubscriptionExperiment::CONTROL];
expect($control['costCents'])->toBe(200) // (2 + 3) × 40, all mature connections
->and($control['wastedCostCents'])->toBe(120) // only the refunded user's 3 × 40
->and($control['refunded'])->toBe(1);
});
it('scales connection cost by the cost-per-connection argument', function () {
$signup = CarbonImmutable::parse('2026-06-05');