diff --git a/app/Services/Stats/ExperimentFunnelCollector.php b/app/Services/Stats/ExperimentFunnelCollector.php index 2fbed267..4ff4ebdd 100644 --- a/app/Services/Stats/ExperimentFunnelCollector.php +++ b/app/Services/Stats/ExperimentFunnelCollector.php @@ -6,6 +6,7 @@ use App\Features\SubscriptionExperiment; use App\Models\User; use Carbon\CarbonImmutable; use Illuminate\Support\Facades\Cache; +use Illuminate\Support\Facades\Log; use Laravel\Cashier\Cashier; use Laravel\Cashier\Subscription; use Laravel\Pennant\Feature; @@ -92,6 +93,7 @@ class ExperimentFunnelCollector $excluded = (array) config('ai_suggestions.report.excluded_emails', []); $windows = $this->decisionWindows(); $monthlyEquiv = $this->monthlyEquivByPriceId(); + $missingPrices = []; User::query() // Soft-deleted accounts still count: they were assigned a variant and @@ -108,7 +110,7 @@ class ExperimentFunnelCollector 'bankingConnections as connection_count' => fn ($query) => $query->withTrashed(), 'aiConsents as ai_consent_count', ]) - ->chunkById(500, function ($users) use (&$variants, $windows, $now, $monthlyEquiv, $costPerConnectionCents): void { + ->chunkById(500, function ($users) use (&$variants, &$missingPrices, $windows, $now, $monthlyEquiv, $costPerConnectionCents): void { Feature::for($users)->load([SubscriptionExperiment::class]); foreach ($users as $user) { @@ -160,7 +162,13 @@ class ExperimentFunnelCollector if ($netActive) { $row['activeMature']++; - $row['mrrCents'] += (int) ($monthlyEquiv[$subscription->stripe_price] ?? 0); + $priceId = (string) $subscription->stripe_price; + + if ($monthlyEquiv !== [] && ! isset($monthlyEquiv[$priceId])) { + $missingPrices[$priceId] = true; + } + + $row['mrrCents'] += (int) ($monthlyEquiv[$priceId] ?? 0); } else { $row['wastedCostCents'] += $connectionCostCents; } @@ -170,6 +178,12 @@ class ExperimentFunnelCollector } }); + if ($missingPrices !== []) { + Log::warning('Experiment funnel: net-active subscriptions on prices absent from the monthly-equivalent map โ€” their MRR is undercounted as 0.', [ + 'price_ids' => array_keys($missingPrices), + ]); + } + foreach ($variants as $key => $row) { $variants[$key]['netActiveRate'] = $row['assignedMature'] > 0 ? $row['activeMature'] / $row['assignedMature'] @@ -222,8 +236,13 @@ class ExperimentFunnelCollector /** * Monthly-equivalent amount (in cents) for each plan price id, from Stripe. - * Yearly prices are divided by 12. Cached for an hour; returns [] (revenue - * unavailable) if Stripe can't be reached, without caching the failure. + * Yearly prices are divided by 12. Fetched by product so that archived, + * rotated price ids (Stripe mints a new id and transfers the lookup key on + * any amount change) still resolve โ€” otherwise subscriptions on an old id + * would silently contribute 0 to MRR. Falls back to the current lookup keys + * when no product is configured. Foreign-currency and one-off prices are + * skipped. Cached for an hour; returns [] (revenue unavailable) if Stripe + * can't be reached, without caching the failure. * * @return array */ @@ -235,26 +254,40 @@ class ExperimentFunnelCollector return Cache::get($key); } + $productId = config('subscriptions.products.pro'); $lookups = array_values(array_filter([ config('subscriptions.plans.monthly.stripe_lookup_key'), config('subscriptions.plans.yearly.stripe_lookup_key'), ])); - if ($lookups === []) { + if ($productId === null && $lookups === []) { return []; } + $params = $productId !== null + ? ['product' => $productId, 'limit' => 100] + : ['lookup_keys' => $lookups, 'limit' => 10]; + try { - $prices = Cashier::stripe()->prices->all(['lookup_keys' => $lookups, 'limit' => 10]); + $prices = Cashier::stripe()->prices->all($params); } catch (\Throwable) { return []; } + $currency = strtolower((string) config('cashier.currency', 'eur')); $map = []; foreach ($prices->data as $price) { + if ($price->recurring === null) { + continue; + } + + if (strtolower((string) ($price->currency ?? $currency)) !== $currency) { + continue; + } + $amount = (int) ($price->unit_amount ?? 0); $map[$price->id] = ($price->recurring->interval ?? 'month') === 'year' - ? intdiv($amount, 12) + ? (int) round($amount / 12) : $amount; } diff --git a/tests/Feature/SendExperimentFunnelReportCommandTest.php b/tests/Feature/SendExperimentFunnelReportCommandTest.php index e26d74a6..c4111e7d 100644 --- a/tests/Feature/SendExperimentFunnelReportCommandTest.php +++ b/tests/Feature/SendExperimentFunnelReportCommandTest.php @@ -9,6 +9,7 @@ use Carbon\CarbonImmutable; use Illuminate\Support\Carbon; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Http; +use Illuminate\Support\Facades\Log; use Illuminate\Support\Str; use function Pest\Laravel\artisan; @@ -173,6 +174,25 @@ it('computes connection cost, wasted burn and contribution margin', function () ->and($control['activationToPaidRate'])->toBe(0.5); // 1 paid รท 2 activated }); +it('warns instead of silently zeroing MRR when a paid sub is on an unmapped (rotated) price', function () { + Log::spy(); + $signup = CarbonImmutable::parse('2026-06-05'); + + // The seeded map only knows 'price_test'; move this paid sub onto a rotated id. + $user = experimentUser(SubscriptionExperiment::REDUCED_TRIAL, $signup, ['status' => 'active', 'at' => $signup]); + $user->subscriptions()->first()->update(['stripe_price' => 'price_rotated_old']); + + $reduced = app(ExperimentFunnelCollector::class)->collect()['variants'][SubscriptionExperiment::REDUCED_TRIAL]; + + expect($reduced['activeMature'])->toBe(1) + ->and($reduced['mrrCents'])->toBe(0); // unmapped price โ†’ 0, but now loudly + + Log::shouldHaveReceived('warning') + ->withArgs(fn (string $message, array $context = []) => str_contains($message, 'absent from the monthly-equivalent map') + && in_array('price_rotated_old', $context['price_ids'] ?? [], true)) + ->once(); +}); + it('still counts soft-deleted users so their assignment and connection cost survive', function () { $signup = CarbonImmutable::parse('2026-06-05');