feat(stats): add unit-economics funnel + connection cost to experiment report (#666)

## What

Reworks `stats:experiment-funnel` from a pure conversion funnel into a
**contribution-margin** one, so the 3-way trial/pricing experiment can
be judged on margin — not just conversion. Signups aren't free: each
bank connection costs money per month, so trials that connect banks and
never pay are burned cash, which the old report was blind to.

## Funnel

Per variant: **assigned → activated → carded → net-paying**

- **activated** = connected ≥1 bank connection **OR** gave AI consent
(triggered paid infrastructure), whether or not they paid.
- **carded** = completed Stripe Checkout (card on file).
- **net-paying** = live, non-refunded, mature subscription.

The **activated → carded** gap is where a user connects a bank and walks
away without paying — the exact leak the report now puts a number on.

## Metrics

| Column | Meaning |
|---|---|
| `A2P%` | net-paying ÷ activated (mature) |
| `Cost` | mature-cohort connections × cost/connection |
| `Burn` | connection cost of mature non-payers (money lost) |
| `CM` | MRR − Cost (the decision metric) |

- New `--cost-per-connection` option, default **0.40** per connection.
- Connection count includes soft-deleted/revoked connections (they still
cost money).
- All money/rate columns are gated on each variant's maturity window;
`Cost`/`Burn`/`CM` print `—` until a variant has mature volume. Legend
notes that raw `Assg`/`Actd`/`Card` are lifetime counts while
rates/money are mature-cohort only.
- Existing MRR/ARPU/Net% fields stay on the collector; dropped from the
printed table.

## Tests

Added coverage for activation (bank OR AI),
cost/burn/contribution-margin math, and the cost-per-connection
argument. Full file green (12 passed).
This commit is contained in:
Víctor Falcón 2026-07-10 20:06:01 +02:00 committed by GitHub
parent 815ca6244c
commit dada23cd84
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 136 additions and 27 deletions

View File

@ -10,7 +10,9 @@ use Illuminate\Console\Command;
class SendExperimentFunnelReportCommand extends Command
{
protected $signature = 'stats:experiment-funnel {--no-discord : Print the report to the console only, without posting to Discord}';
protected $signature = 'stats:experiment-funnel
{--no-discord : Print the report to the console only, without posting to Discord}
{--cost-per-connection=0.4 : Estimated cost (in the Cashier currency) per bank connection, used for the Cost/Burn/CM columns}';
protected $description = 'Post the trial/pricing experiment funnel (per variant) to Discord';
@ -27,7 +29,8 @@ class SendExperimentFunnelReportCommand extends Command
public function handle(): int
{
$report = $this->collector->collect();
$costPerConnectionCents = (int) round(((float) $this->option('cost-per-connection')) * 100);
$report = $this->collector->collect($costPerConnectionCents);
if ($report['startedAt'] === null) {
$this->warn('Experiment not started — set SUBSCRIPTION_EXPERIMENT_STARTED_AT to begin.');
@ -56,31 +59,31 @@ class SendExperimentFunnelReportCommand extends Command
}
/**
* @param array{startedAt: ?CarbonImmutable, currency: string, revenueAvailable: bool, variants: array<string, array<string, mixed>>} $report
* @param array{startedAt: ?CarbonImmutable, currency: string, revenueAvailable: bool, costPerConnectionCents: int, variants: array<string, array<string, mixed>>} $report
* @return list<string>
*/
private function tableLines(array $report): array
{
$revenue = $report['revenueAvailable'];
$lines = [sprintf('%-8s %5s %4s %5s %5s %5s %5s %5s %5s %8s %8s', 'Variant', 'Assg', 'Sub', 'Trl', 'TrlX', 'Actv', 'Cncl', 'Rfnd', 'Net%', 'MRR', 'ARPU')];
$currency = $report['currency'];
$lines = [sprintf('%-8s %5s %5s %5s %5s %6s %8s %8s %8s %8s', 'Variant', 'Assg', 'Actd', 'Card', 'Paid', 'A2P%', 'MRR', 'Cost', 'Burn', 'CM')];
foreach (self::LABELS as $key => $label) {
$row = $report['variants'][$key];
$mature = $row['assignedMature'] > 0;
$mature = $row['activatedMature'] > 0;
$lines[] = sprintf(
'%-8s %5d %4d %5d %5d %5d %5d %5d %5s %8s %8s',
'%-8s %5d %5d %5d %5d %6s %8s %8s %8s %8s',
$label,
$row['assigned'],
$row['activated'],
$row['subscribed'],
$row['trialing'],
$row['trialingCanceling'],
$row['active'],
$row['canceled'],
$row['refunded'],
$mature ? ((int) round($row['netActiveRate'] * 100)).'%' : 'pend',
$revenue ? $this->money($row['mrrCents'], $report['currency']) : '—',
$revenue && $mature ? $this->money((int) $row['arpuCents'], $report['currency']) : '—',
$row['activeMature'],
$mature ? ((int) round($row['activationToPaidRate'] * 100)).'%' : 'pend',
$revenue ? $this->money($row['mrrCents'], $currency) : '—',
$mature ? $this->money($row['costCents'], $currency) : '—',
$mature ? $this->money($row['wastedCostCents'], $currency) : '—',
$revenue && $mature ? $this->money($row['contributionMarginCents'], $currency) : '—',
);
}
@ -100,7 +103,7 @@ class SendExperimentFunnelReportCommand extends Command
}
/**
* @param array{startedAt: ?CarbonImmutable, currency: string, revenueAvailable: bool, variants: array<string, array<string, mixed>>} $report
* @param array{startedAt: ?CarbonImmutable, currency: string, revenueAvailable: bool, costPerConnectionCents: int, variants: array<string, array<string, mixed>>} $report
* @return array<string, mixed>
*/
private function buildEmbed(array $report): array
@ -117,12 +120,15 @@ class SendExperimentFunnelReportCommand extends Command
],
[
'name' => 'Legend',
'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.',
'value' => sprintf(
'Assg = signups · Actd = activated (connected a bank or enabled AI = cost triggered) · Card = completed checkout (card on file) · Paid = live non-refunded subs (mature) · A2P%% = Paid ÷ activated (mature) · MRR = monthly run-rate of paid subs (yearly ÷ 12) · Cost = est. connection cost of the mature cohort (%s/connection) · Burn = connection cost of mature users who never converted (money lost) · CM = MRR Cost · `pend`/`—` = no mature data yet.',
$this->money($report['costPerConnectionCents'], $report['currency']),
),
'inline' => false,
],
[
'name' => '⚠️ How to read it',
'value' => 'Each variant is gated by its own decision window (control 15d, reduced 7d, pay_now 3d), so pay_now matures first — compare only once all three have mature volume, and check significance before calling a winner. **ARPU is the revenue metric to compare.** MRR is run-rate, so it does not credit pay_now\'s yearly upfront cash; true LTV also needs a churn rate the experiment is too young to have.',
'value' => 'Each variant is gated by its own decision window (control 15d, reduced 7d, pay_now 3d), so pay_now matures first — compare only once all three have mature volume, and check significance before calling a winner. **CM (contribution margin) is the decision metric.** Assg/Actd/Card are lifetime counts; A2P%/Cost/Burn/CM cover only the mature cohort, so the raw Actd→Card→Paid funnel mixes cohorts (immature carded users can\'t be Paid yet) — read it for volume, compare variants on A2P%/CM. Burn is what the connect-and-leave leak costs. Cost is a flat per-connection estimate across all providers, not per-provider billing.',
'inline' => false,
],
],

View File

@ -27,19 +27,31 @@ class ExperimentFunnelCollector
* subscription an exact, heuristic-free metric that is comparable across
* variants once each cohort clears its own decision window.
*
* The funnel is assigned activated carded (subscribed) net-paying:
* "activated" = the user connected a bank or enabled AI, i.e. triggered the
* paid infrastructure that costs us money, whether or not they ever paid. The
* gap activated carded (completed Checkout with a card) is where a user
* connects a bank and walks away without paying the exact leak the flat
* per-connection cost estimate quantifies.
*
* Revenue is the monthly-recurring run-rate (MRR) of the mature, net-active
* subscriptions, with yearly plans normalised to a monthly equivalent, plus
* ARPU = MRR ÷ assigned (mature) the per-signup revenue each variant earns.
* This is run-rate, not realised cash, so it does not credit pay_now's yearly
* upfront payment any differently from a monthly one. Full LTV needs a churn
* rate the experiment is too young to have; ARPU is the proxy until then.
* ARPU = MRR ÷ assigned (mature). Cost is a flat estimate: connections of the
* mature cohort × `$costPerConnectionCents`; "wasted" cost is the same for
* mature users who did not convert (money burned). Contribution margin =
* MRR cost, the decision metric once every variant has mature volume. This
* is run-rate, not realised cash, so it does not credit pay_now's yearly
* upfront payment any differently from a monthly one.
*
* @param int $costPerConnectionCents flat estimated cost per bank connection
* @return array{
* startedAt: ?CarbonImmutable,
* currency: string,
* revenueAvailable: bool,
* costPerConnectionCents: int,
* variants: array<string, array{
* assigned: int,
* activated: int,
* subscribed: int,
* trialing: int,
* trialingCanceling: int,
@ -48,14 +60,19 @@ class ExperimentFunnelCollector
* pastDue: int,
* refunded: int,
* assignedMature: int,
* activatedMature: int,
* activeMature: int,
* netActiveRate: ?float,
* activationToPaidRate: ?float,
* mrrCents: int,
* arpuCents: ?int,
* costCents: int,
* wastedCostCents: int,
* contributionMarginCents: int,
* }>
* }
*/
public function collect(): array
public function collect(int $costPerConnectionCents = 40): array
{
$startedValue = config('subscriptions.experiment.started_at');
$startedAt = $startedValue !== null ? CarbonImmutable::parse($startedValue) : null;
@ -68,7 +85,7 @@ class ExperimentFunnelCollector
];
if ($startedAt === null) {
return ['startedAt' => null, 'currency' => $currency, 'revenueAvailable' => false, 'variants' => $variants];
return ['startedAt' => null, 'currency' => $currency, 'revenueAvailable' => false, 'costPerConnectionCents' => $costPerConnectionCents, 'variants' => $variants];
}
$now = CarbonImmutable::now('UTC');
@ -81,7 +98,13 @@ class ExperimentFunnelCollector
->when($excluded !== [], fn ($query) => $query->whereNotIn('email', $excluded))
->with(['subscriptions' => fn ($query) => $query->where('type', 'default')])
->select(['id', 'created_at'])
->chunkById(500, function ($users) use (&$variants, $windows, $now, $monthlyEquiv): void {
->withCount([
// Cost is incurred by every connection ever opened, so count
// soft-deleted (revoked) ones too — they still cost us money.
'bankingConnections as connection_count' => fn ($query) => $query->withTrashed(),
'aiConsents as ai_consent_count',
])
->chunkById(500, function ($users) use (&$variants, $windows, $now, $monthlyEquiv, $costPerConnectionCents): void {
Feature::for($users)->load([SubscriptionExperiment::class]);
foreach ($users as $user) {
@ -95,6 +118,13 @@ class ExperimentFunnelCollector
$row['assigned']++;
$connections = (int) ($user->connection_count ?? 0);
$activated = $connections > 0 || (int) ($user->ai_consent_count ?? 0) > 0;
if ($activated) {
$row['activated']++;
}
/** @var Subscription|null $subscription */
$subscription = $user->subscriptions->sortByDesc('created_at')->first();
$status = $subscription?->stripe_status;
@ -117,9 +147,18 @@ class ExperimentFunnelCollector
if ($mature) {
$row['assignedMature']++;
$connectionCostCents = $connections * $costPerConnectionCents;
$row['costCents'] += $connectionCostCents;
if ($activated) {
$row['activatedMature']++;
}
if ($netActive) {
$row['activeMature']++;
$row['mrrCents'] += (int) ($monthlyEquiv[$subscription->stripe_price] ?? 0);
} else {
$row['wastedCostCents'] += $connectionCostCents;
}
}
@ -131,26 +170,32 @@ class ExperimentFunnelCollector
$variants[$key]['netActiveRate'] = $row['assignedMature'] > 0
? $row['activeMature'] / $row['assignedMature']
: null;
$variants[$key]['activationToPaidRate'] = $row['activatedMature'] > 0
? $row['activeMature'] / $row['activatedMature']
: null;
$variants[$key]['arpuCents'] = $row['assignedMature'] > 0
? (int) round($row['mrrCents'] / $row['assignedMature'])
: null;
$variants[$key]['contributionMarginCents'] = $row['mrrCents'] - $row['costCents'];
}
return [
'startedAt' => $startedAt,
'currency' => $currency,
'revenueAvailable' => $monthlyEquiv !== [],
'costPerConnectionCents' => $costPerConnectionCents,
'variants' => $variants,
];
}
/**
* @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}
* @return array{assigned: int, activated: int, subscribed: int, trialing: int, trialingCanceling: int, active: int, canceled: int, pastDue: int, refunded: int, assignedMature: int, activatedMature: int, activeMature: int, netActiveRate: ?float, activationToPaidRate: ?float, mrrCents: int, arpuCents: ?int, costCents: int, wastedCostCents: int, contributionMarginCents: int}
*/
private function emptyRow(): array
{
return [
'assigned' => 0,
'activated' => 0,
'subscribed' => 0,
'trialing' => 0,
'trialingCanceling' => 0,
@ -159,10 +204,15 @@ class ExperimentFunnelCollector
'pastDue' => 0,
'refunded' => 0,
'assignedMature' => 0,
'activatedMature' => 0,
'activeMature' => 0,
'netActiveRate' => null,
'activationToPaidRate' => null,
'mrrCents' => 0,
'arpuCents' => null,
'costCents' => 0,
'wastedCostCents' => 0,
'contributionMarginCents' => 0,
];
}

View File

@ -1,6 +1,8 @@
<?php
use App\Features\SubscriptionExperiment;
use App\Models\AiConsent;
use App\Models\BankingConnection;
use App\Models\User;
use App\Services\Stats\ExperimentFunnelCollector;
use Carbon\CarbonImmutable;
@ -29,11 +31,12 @@ beforeEach(function () {
/**
* Create a user whose id buckets into the wanted variant, anchored to a signup,
* with an optional default subscription.
* with an optional default subscription and any bank connections / AI consent
* that mark the user as "activated" and drive the connection-cost columns.
*
* @param array{status: string, at: CarbonImmutable, endsAt?: CarbonImmutable, refundedAt?: CarbonImmutable}|null $subscription
*/
function experimentUser(string $variant, CarbonImmutable $signup, ?array $subscription = null): User
function experimentUser(string $variant, CarbonImmutable $signup, ?array $subscription = null, int $connections = 0, bool $aiConsent = false): User
{
do {
$id = (string) Str::uuid();
@ -53,6 +56,14 @@ function experimentUser(string $variant, CarbonImmutable $signup, ?array $subscr
]);
}
if ($connections > 0) {
BankingConnection::factory()->count($connections)->for($user)->create();
}
if ($aiConsent) {
AiConsent::factory()->for($user)->create();
}
return $user;
}
@ -131,6 +142,48 @@ it('computes MRR and ARPU from the net-active subscriptions', function () {
->and($payNow['arpuCents'])->toBe(0);
});
it('marks a user activated when they connect a bank or enable AI', function () {
$signup = CarbonImmutable::parse('2026-06-05');
experimentUser(SubscriptionExperiment::CONTROL, $signup); // neither → not activated
experimentUser(SubscriptionExperiment::CONTROL, $signup, connections: 1); // bank → activated
experimentUser(SubscriptionExperiment::CONTROL, $signup, aiConsent: true); // AI → activated
$control = app(ExperimentFunnelCollector::class)->collect()['variants'][SubscriptionExperiment::CONTROL];
expect($control['assigned'])->toBe(3)
->and($control['activated'])->toBe(2)
->and($control['activatedMature'])->toBe(2);
});
it('computes connection cost, wasted burn and contribution margin', function () {
$signup = CarbonImmutable::parse('2026-06-05');
// Converted control user with 2 connections: cost 2×€0.40, no burn, MRR €3.99.
experimentUser(SubscriptionExperiment::CONTROL, $signup, ['status' => 'active', 'at' => $signup->addDay()], connections: 2);
// Activated non-payer with 3 connections: pure burn, no revenue.
experimentUser(SubscriptionExperiment::CONTROL, $signup, null, connections: 3);
$control = app(ExperimentFunnelCollector::class)->collect()['variants'][SubscriptionExperiment::CONTROL];
expect($control['costCents'])->toBe(200) // (2+3) × 40
->and($control['wastedCostCents'])->toBe(120) // 3 × 40, the non-payer
->and($control['mrrCents'])->toBe(399)
->and($control['contributionMarginCents'])->toBe(199) // 399 200
->and($control['activationToPaidRate'])->toBe(0.5); // 1 paid ÷ 2 activated
});
it('scales connection cost by the cost-per-connection argument', function () {
$signup = CarbonImmutable::parse('2026-06-05');
experimentUser(SubscriptionExperiment::CONTROL, $signup, null, connections: 2);
$control = app(ExperimentFunnelCollector::class)->collect(100)['variants'][SubscriptionExperiment::CONTROL];
expect($control['costCents'])->toBe(200) // 2 × 100
->and($control['wastedCostCents'])->toBe(200);
});
it('marks revenue unavailable when Stripe prices cannot be loaded', function () {
Cache::forget('experiment_funnel_monthly_equiv');
Cache::put('experiment_funnel_monthly_equiv', [], now()->addHour()); // empty = unavailable