fix(stats): resolve MRR for subscriptions on rotated Stripe price ids
monthlyEquivByPriceId keyed the map by the current price ids (fetched by lookup key), so a subscription on a rotated/archived price id matched no key and silently contributed 0 to MRR/CM (prod already spans 4 distinct active price ids). Fetch prices by product instead, which includes archived ids, and warn if any net-active sub still has an unmapped price so it can never be silent again. Also fix the yearly divide to round (was intdiv, always-down) and skip foreign-currency/one-off prices.
This commit is contained in:
parent
b8e1f9c2d8
commit
2eab6f2a76
|
|
@ -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<string, int>
|
||||
*/
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue