laravel-shop/tests/Feature/Subscriptions/MrrMetricsTest.php

222 lines
8.6 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?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, int $costAmount = 0, ?float $licensePercent = null, int $licenseMin = 0): 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,
'cost_amount' => $costAmount,
'license_percent' => $licensePercent,
'license_min_amount' => $licenseMin ?: null,
'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_computes_net_mrr_after_cost_of_goods(): void
{
$seat = $this->product('Full Seat');
// €25.50/mo, with a €15.00/mo license cost basis (COGS).
$this->price($seat, 'price_seat', 2550, RecurringInterval::MONTH, 1, 1500);
// €135/yr, with a €12/yr cost (→ €1/mo after ÷12).
$this->price($seat, 'price_year', 13500, RecurringInterval::YEAR, 1, 1200);
$this->subscription($seat, 'active', 'price_seat');
$this->subscription($seat, 'active', 'price_seat');
$this->subscription($seat, 'active', 'price_year');
$m = Shop::subscriptionMetrics();
$expectedMrr = 2 * 2550 + (int) round(13500 / 12);
$expectedCogs = 2 * 1500 + (int) round(1200 / 12);
$this->assertSame($expectedMrr, $m['mrr']);
$this->assertSame($expectedCogs, $m['cogs']);
$this->assertSame($expectedMrr - $expectedCogs, $m['net_mrr']);
$this->assertSame($expectedMrr - $expectedCogs, Shop::netMrr());
}
#[Test]
public function it_amortizes_the_licence_royalty_over_the_quarter_not_the_billing_cycle(): void
{
$seat = $this->product('Full Seat');
// €25.50/mo Full Seat, Aircademy-style rule: 35% of list, floored at €15.
// 35% × €25.50 = €8.93 < €15, so the €15 minimum applies — per QUARTER.
$this->price($seat, 'price_seat', 2550, RecurringInterval::MONTH, 1, 0, 35.0, 1500);
$this->subscription($seat, 'active', 'price_seat');
$m = Shop::subscriptionMetrics();
// €15 ÷ the 3-month bracket = €5/month → €15 across a quarter, i.e. a
// monthly subscriber billed 3× still costs ONE quarterly fee, not three.
$this->assertSame(500, $m['license_cost']);
$this->assertSame(2550, $m['mrr']);
$this->assertSame(2550 - 500, $m['net_mrr']);
$this->assertSame(2550 - 500, Shop::netMrr());
}
#[Test]
public function the_royalty_takes_the_percentage_when_it_beats_the_minimum(): void
{
$q = $this->product('Questions');
// €49 / 3 months: 35% × €49 = €17.15 > €15 min → percentage wins, per quarter.
$this->price($q, 'price_q', 4900, RecurringInterval::MONTH, 3, 0, 35.0, 1500);
$this->subscription($q, 'active', 'price_q');
$m = Shop::subscriptionMetrics();
$perQuarter = (int) round(0.35 * 4900); // 1715
$this->assertSame((int) round($perQuarter / 3), $m['license_cost']); // ÷ 3-month bracket
}
#[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()));
}
}