feat(metrics): subscription MRR API on Shop facade

Add Shop::mrr(), activeSubscribers(), mrrByProduct(), subscriptionMetrics()
and subscriberStatuses() to ShopService. Counts active/past_due/trialing
subscribers (not `ends_at IS NULL`, which silently drops still-billing
cancel-at-period-end subs and under-reports MRR), normalizes every interval
to a monthly run-rate (year/12, quarter/3, week & day by the average
days-per-month constant so they match Stripe), and returns `unpriced_items`
so a stale or incomplete price mirror is surfaced, never a silent undercount.

Additive only: no existing method changed. Covered by MrrMetricsTest.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Fabian @ Blax Software 2026-08-28 13:38:42 +02:00
parent 2c31a23255
commit f6f8916bed
3 changed files with 369 additions and 1 deletions

View File

@ -54,6 +54,13 @@ use Illuminate\Support\Facades\Facade;
* @method static \Illuminate\Support\Collection revenueByDay(\DateTimeInterface $from, \DateTimeInterface $until) * @method static \Illuminate\Support\Collection revenueByDay(\DateTimeInterface $from, \DateTimeInterface $until)
* @method static \Illuminate\Support\Collection revenueByMonth(\DateTimeInterface $from, \DateTimeInterface $until) * @method static \Illuminate\Support\Collection revenueByMonth(\DateTimeInterface $from, \DateTimeInterface $until)
* *
* Subscription Revenue (MRR):
* @method static array subscriberStatuses()
* @method static int activeSubscribers()
* @method static int mrr()
* @method static \Illuminate\Support\Collection mrrByProduct()
* @method static array subscriptionMetrics()
*
* Cart Queries: * Cart Queries:
* @method static \Illuminate\Database\Eloquent\Builder carts() * @method static \Illuminate\Database\Eloquent\Builder carts()
* @method static \Illuminate\Database\Eloquent\Builder activeCarts() * @method static \Illuminate\Database\Eloquent\Builder activeCarts()

View File

@ -5,12 +5,15 @@ declare(strict_types=1);
namespace Blax\Shop\Services; namespace Blax\Shop\Services;
use Blax\Shop\Enums\OrderStatus; use Blax\Shop\Enums\OrderStatus;
use Blax\Shop\Enums\RecurringInterval;
use Blax\Shop\Models\Cart; use Blax\Shop\Models\Cart;
use Blax\Shop\Models\CartItem; use Blax\Shop\Models\CartItem;
use Blax\Shop\Models\Order; use Blax\Shop\Models\Order;
use Blax\Shop\Models\Product; use Blax\Shop\Models\Product;
use Blax\Shop\Models\ProductCategory; use Blax\Shop\Models\ProductCategory;
use Blax\Shop\Models\ProductPrice;
use Blax\Shop\Models\ProductPurchase; use Blax\Shop\Models\ProductPurchase;
use Blax\Shop\Models\Subscription;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Collection;
use Carbon\Carbon; use Carbon\Carbon;
@ -509,6 +512,202 @@ class ShopService
->get(); ->get();
} }
// =========================================================================
// SUBSCRIPTION REVENUE (MRR)
// =========================================================================
/**
* Subscription statuses that count as a live, revenue-bearing subscriber.
*
* Mirrors Stripe's "active subscribers" metric: a subscription still
* billing counts even when it is set to cancel at period end (status stays
* `active` with a future `ends_at`) or is retrying payment (`past_due`).
* Filtering on `ends_at IS NULL` — a common mistake — silently drops
* paying, cancel-at-period-end subscribers and under-reports MRR.
* Override via `config('shop.metrics.subscription_statuses')`.
*
* @return array<int, string>
*/
public function subscriberStatuses(): array
{
return config('shop.metrics.subscription_statuses', ['active', 'past_due', 'trialing']);
}
/**
* Base query for the subscriptions counted in subscriber/MRR metrics.
*
* @return Builder<Subscription>
*/
protected function metricSubscriptions(): Builder
{
$model = config('shop.models.subscription', Subscription::class);
return $model::query()->whereIn('stripe_status', $this->subscriberStatuses());
}
/**
* Number of live subscribers (see {@see subscriberStatuses()}).
*/
public function activeSubscribers(): int
{
return $this->metricSubscriptions()->count();
}
/**
* Monthly Recurring Revenue in cents.
*
* Every live subscription's line items, each normalized to a monthly
* run-rate from its price interval (year ÷ 12, quarter ÷ 3, week/day by the
* average-days-per-month constant so weekly/daily match Stripe's own MRR
* normalization). Computed from the mirrored {@see ProductPrice} rows — no
* Stripe API call — so the total is only as accurate as the mirror:
* a `product_prices.unit_amount` that has drifted from the live Stripe
* price will skew it, and a line item whose price is not mirrored at all is
* excluded. Use {@see subscriptionMetrics()} to see that excluded count
* (`unpriced_items`) rather than trusting a silent number, and keep the
* mirror synced from Stripe for an exact figure.
*/
public function mrr(): int
{
return $this->subscriptionMetrics()['mrr'];
}
/**
* MRR split by product slug, in cents, highest first.
*
* @return \Illuminate\Support\Collection<string, int>
*/
public function mrrByProduct(): \Illuminate\Support\Collection
{
return collect($this->subscriptionMetrics()['by_product']);
}
/**
* Every subscription metric in a single pass over the live subscriptions:
* subscriber count, MRR (cents), MRR by product slug, and the number of
* line items whose price could not be resolved from the mirror — so an
* under-count is surfaced, never silent.
*
* @return array{subscribers: int, mrr: int, by_product: array<string, int>, unpriced_items: int}
*/
public function subscriptionMetrics(): array
{
$subscriptions = $this->metricSubscriptions()->with(['items', 'product'])->get();
// Resolve every referenced price in one query, keyed by Stripe price id.
$priceModel = config('shop.models.product_price', ProductPrice::class);
$priceIds = $subscriptions
->flatMap(fn ($sub) => $sub->items->pluck('stripe_price'))
->filter()
->unique()
->values();
$prices = $priceModel::query()
->whereIn('stripe_price_id', $priceIds->all())
->with('purchasable')
->get()
->keyBy('stripe_price_id');
$mrr = 0.0;
$byProduct = [];
$unpricedItems = 0;
foreach ($subscriptions as $subscription) {
foreach ($subscription->items as $item) {
$price = $prices->get($item->stripe_price);
if ($price === null) {
$unpricedItems++;
continue;
}
$monthly = $this->priceMonthlyAmount($price, (int) ($item->quantity ?? 1));
if ($monthly === null) {
// Price resolved but is not recurring (a one-time price on a
// subscription line) — carries no MRR, and is not a data gap.
continue;
}
$mrr += $monthly;
$slug = $this->priceProductSlug($price)
?? $this->subscriptionProductSlug($subscription)
?? 'unknown';
$byProduct[$slug] = ($byProduct[$slug] ?? 0) + $monthly;
}
}
arsort($byProduct);
return [
'subscribers' => $subscriptions->count(),
'mrr' => (int) round($mrr),
'by_product' => array_map(static fn ($cents) => (int) round($cents), $byProduct),
'unpriced_items' => $unpricedItems,
];
}
/**
* Normalize a recurring price to a monthly amount in cents for `$quantity`
* units. Returns null for a non-recurring (one-time) price, which carries
* no MRR. Weekly and daily use the Gregorian average-days-per-month
* constant so they line up with Cashier/Stripe's MRR normalization.
*/
protected function priceMonthlyAmount(ProductPrice $price, int $quantity = 1): ?int
{
$interval = $price->interval instanceof RecurringInterval
? $price->interval->value
: (is_string($price->interval) ? $price->interval : null);
if ($interval === null) {
return null; // one-time price — not recurring revenue
}
$amount = (float) $price->unit_amount * max(1, $quantity);
$count = max(1, (int) ($price->interval_count ?: 1));
$daysPerMonth = 30.436875; // Gregorian average month length
$monthly = match ($interval) {
'year' => $amount / (12 * $count),
'quarter' => $amount / (3 * $count),
'month' => $amount / $count,
'week' => $amount / ($count * 7 / $daysPerMonth),
'day' => $amount / ($count / $daysPerMonth),
default => null,
};
return $monthly === null ? null : (int) round($monthly);
}
/**
* Product slug carried by a price's purchasable (usually a Product), or
* null when the purchasable is absent or has no slug/name.
*/
protected function priceProductSlug(ProductPrice $price): ?string
{
$purchasable = $price->purchasable;
if (! is_object($purchasable)) {
return null;
}
return $purchasable->slug ?? $purchasable->name ?? null;
}
/**
* Product slug for the subscription's linked product, or null.
*/
protected function subscriptionProductSlug(Subscription $subscription): ?string
{
$product = $subscription->product;
if (! is_object($product)) {
return null;
}
return $product->slug ?? $product->name ?? null;
}
// ========================================================================= // =========================================================================
// CART QUERIES // CART QUERIES
// ========================================================================= // =========================================================================

View File

@ -0,0 +1,162 @@
<?php
namespace Blax\Shop\Tests\Feature\Subscriptions;
use Blax\Shop\Enums\PriceType;
use Blax\Shop\Enums\ProductStatus;
use Blax\Shop\Enums\ProductType;
use Blax\Shop\Enums\RecurringInterval;
use Blax\Shop\Facades\Shop;
use Blax\Shop\Models\Product;
use Blax\Shop\Models\ProductPrice;
use Blax\Shop\Models\Subscription;
use Blax\Shop\Models\SubscriptionItem;
use Blax\Shop\Tests\TestCase;
use Illuminate\Foundation\Testing\RefreshDatabase;
use PHPUnit\Framework\Attributes\Test;
use Workbench\App\Models\User;
/**
* Covers Shop::mrr()/activeSubscribers()/mrrByProduct()/subscriptionMetrics().
*
* Regression anchors for the two ways an app's hand-rolled MRR goes wrong:
* excluding still-billing subscribers (past_due, cancel-at-period-end), and
* silently dropping line items whose price is not mirrored locally.
*/
class MrrMetricsTest extends TestCase
{
use RefreshDatabase;
private string $userId;
protected function setUp(): void
{
parent::setUp();
$this->userId = (string) User::factory()->create()->getKey();
}
private function product(string $name): Product
{
return Product::create([
'name' => $name,
'sku' => 'SKU-'.uniqid(),
'type' => ProductType::SIMPLE,
'status' => ProductStatus::PUBLISHED,
'manage_stock' => false,
]);
}
private function price(Product $product, string $stripePrice, int $unitAmount, RecurringInterval|string|null $interval, int $intervalCount = 1): ProductPrice
{
return ProductPrice::create([
'purchasable_type' => Product::class,
'purchasable_id' => $product->id,
'stripe_price_id' => $stripePrice,
'type' => $interval === null ? PriceType::ONE_TIME : PriceType::RECURRING,
'currency' => 'EUR',
'unit_amount' => $unitAmount,
'is_default' => true,
'active' => true,
'interval' => $interval,
'interval_count' => $intervalCount,
]);
}
private function subscription(Product $product, string $status, string $stripePrice, ?\DateTimeInterface $endsAt = null): Subscription
{
$sub = Subscription::create([
'user_id' => $this->userId,
'product_id' => $product->id,
'type' => 'default',
'stripe_id' => 'sub_'.uniqid(),
'stripe_status' => $status,
'stripe_price' => $stripePrice,
'quantity' => 1,
'ends_at' => $endsAt,
]);
SubscriptionItem::create([
'subscription_id' => $sub->id,
'stripe_id' => 'si_'.uniqid(),
'stripe_product' => 'prod_x',
'stripe_price' => $stripePrice,
'quantity' => 1,
]);
return $sub;
}
#[Test]
public function it_counts_live_subscribers_including_past_due_and_cancel_at_period_end(): void
{
$seat = $this->product('Full Seat');
$this->price($seat, 'price_seat', 2550, RecurringInterval::MONTH);
$this->subscription($seat, 'active', 'price_seat');
$this->subscription($seat, 'past_due', 'price_seat'); // still billing -> counts
$this->subscription($seat, 'active', 'price_seat', now()->addMonth()); // cancel-at-period-end -> counts
$this->subscription($seat, 'trialing', 'price_seat'); // counts
$this->subscription($seat, 'canceled', 'price_seat'); // gone -> excluded
$this->assertSame(4, Shop::activeSubscribers());
$this->assertSame(4 * 2550, Shop::mrr());
}
#[Test]
public function it_normalizes_every_interval_to_a_monthly_run_rate(): void
{
$p = $this->product('Simulator');
$this->price($p, 'price_month', 1410, RecurringInterval::MONTH);
$this->price($p, 'price_year', 13500, RecurringInterval::YEAR); // ÷12
$this->price($p, 'price_week', 610, RecurringInterval::WEEK); // ×30.436875/7
$this->price($p, 'price_quarter', 4900, RecurringInterval::MONTH, 3); // ÷3
$this->subscription($p, 'active', 'price_month');
$this->subscription($p, 'active', 'price_year');
$this->subscription($p, 'active', 'price_week');
$this->subscription($p, 'active', 'price_quarter');
$expected = 1410
+ (int) round(13500 / 12)
+ (int) round(610 * 30.436875 / 7)
+ (int) round(4900 / 3);
$this->assertSame($expected, Shop::mrr());
}
#[Test]
public function it_surfaces_line_items_whose_price_is_not_mirrored_rather_than_guessing(): void
{
$p = $this->product('Full Seat');
$this->price($p, 'price_seat', 2550, RecurringInterval::MONTH);
$this->subscription($p, 'active', 'price_seat');
$this->subscription($p, 'active', 'price_missing'); // no ProductPrice mirrored
$metrics = Shop::subscriptionMetrics();
$this->assertSame(2550, $metrics['mrr']); // unmirrored item excluded, never guessed
$this->assertSame(1, $metrics['unpriced_items']); // but surfaced, not silent
$this->assertSame(2, $metrics['subscribers']);
}
#[Test]
public function it_splits_mrr_by_product(): void
{
$seat = $this->product('Full Seat');
$sim = $this->product('Simulator');
$this->price($seat, 'price_seat', 2550, RecurringInterval::MONTH);
$this->price($sim, 'price_sim', 890, RecurringInterval::MONTH);
$this->subscription($seat, 'active', 'price_seat');
$this->subscription($seat, 'active', 'price_seat');
$this->subscription($sim, 'active', 'price_sim');
$byProduct = Shop::mrrByProduct();
$this->assertSame(Shop::mrr(), $byProduct->sum());
$this->assertCount(2, $byProduct);
// arsort() puts the biggest product first; values are label-independent.
$this->assertSame([5100, 890], array_values($byProduct->all()));
}
}