diff --git a/app/Console/Commands/SendExperimentFunnelReportCommand.php b/app/Console/Commands/SendExperimentFunnelReportCommand.php index d5890ccc..f8bc6116 100644 --- a/app/Console/Commands/SendExperimentFunnelReportCommand.php +++ b/app/Console/Commands/SendExperimentFunnelReportCommand.php @@ -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, diff --git a/app/Services/Stats/ExperimentFunnelCollector.php b/app/Services/Stats/ExperimentFunnelCollector.php index 2a6bf9eb..7508c7de 100644 --- a/app/Services/Stats/ExperimentFunnelCollector.php +++ b/app/Services/Stats/ExperimentFunnelCollector.php @@ -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; } } diff --git a/tests/Feature/SendExperimentFunnelReportCommandTest.php b/tests/Feature/SendExperimentFunnelReportCommandTest.php index 79d63cb3..a007d732 100644 --- a/tests/Feature/SendExperimentFunnelReportCommandTest.php +++ b/tests/Feature/SendExperimentFunnelReportCommandTest.php @@ -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');