222 lines
8.6 KiB
PHP
222 lines
8.6 KiB
PHP
<?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()));
|
||
}
|
||
}
|