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:
parent
2c31a23255
commit
f6f8916bed
|
|
@ -53,7 +53,14 @@ use Illuminate\Support\Facades\Facade;
|
||||||
* @method static array stats()
|
* @method static array stats()
|
||||||
* @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()
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
|
|
|
||||||
|
|
@ -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()));
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue