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

222 lines
8.6 KiB
PHP
Raw Normal View History

<?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()));
}
}