diff --git a/database/migrations/2026_01_01_000004_add_cost_amount_to_product_prices.php b/database/migrations/2026_01_01_000004_add_cost_amount_to_product_prices.php new file mode 100644 index 0000000..b13138d --- /dev/null +++ b/database/migrations/2026_01_01_000004_add_cost_amount_to_product_prices.php @@ -0,0 +1,36 @@ +integer('cost_amount')->default(0)->after('sale_unit_amount'); + }); + } + } + + public function down(): void + { + $table = config('shop.tables.product_prices', 'product_prices'); + + if (Schema::hasColumn($table, 'cost_amount')) { + Schema::table($table, function (Blueprint $t) { + $t->dropColumn('cost_amount'); + }); + } + } +}; diff --git a/src/Facades/Shop.php b/src/Facades/Shop.php index 989828a..735bef0 100644 --- a/src/Facades/Shop.php +++ b/src/Facades/Shop.php @@ -58,6 +58,7 @@ use Illuminate\Support\Facades\Facade; * @method static array subscriberStatuses() * @method static int activeSubscribers() * @method static int mrr() + * @method static int netMrr() * @method static \Illuminate\Support\Collection mrrByProduct() * @method static array subscriptionMetrics() * diff --git a/src/Models/ProductPrice.php b/src/Models/ProductPrice.php index 1118314..06b06d1 100644 --- a/src/Models/ProductPrice.php +++ b/src/Models/ProductPrice.php @@ -59,6 +59,7 @@ class ProductPrice extends Model implements Cartable 'currency', 'unit_amount', 'sale_unit_amount', + 'cost_amount', 'is_default', 'active', 'billing_scheme', @@ -77,6 +78,7 @@ class ProductPrice extends Model implements Cartable 'meta' => 'object', 'unit_amount' => 'float', 'sale_unit_amount' => 'float', + 'cost_amount' => 'float', 'interval_count' => 'integer', 'trial_period_days' => 'integer', ]; diff --git a/src/Services/ShopService.php b/src/Services/ShopService.php index e10cf31..7b9f1e3 100644 --- a/src/Services/ShopService.php +++ b/src/Services/ShopService.php @@ -608,6 +608,7 @@ class ShopService ->keyBy('stripe_price_id'); $mrr = 0.0; + $cogs = 0.0; $byProduct = []; $unpricedItems = 0; @@ -621,7 +622,8 @@ class ShopService continue; } - $monthly = $this->priceMonthlyAmount($price, (int) ($item->quantity ?? 1)); + $quantity = (int) ($item->quantity ?? 1); + $monthly = $this->priceMonthlyAmount($price, $quantity); if ($monthly === null) { // Price resolved but is not recurring (a one-time price on a @@ -630,6 +632,7 @@ class ShopService } $mrr += $monthly; + $cogs += $this->priceMonthlyCost($price, $quantity) ?? 0; $slug = $this->priceProductSlug($price) ?? $this->subscriptionProductSlug($subscription) ?? 'unknown'; @@ -642,28 +645,60 @@ class ShopService return [ 'subscribers' => $subscriptions->count(), 'mrr' => (int) round($mrr), + 'cogs' => (int) round($cogs), + 'net_mrr' => (int) round($mrr - $cogs), '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. + * Net Monthly Recurring Revenue in cents: {@see mrr()} minus the recurring + * cost of goods (each active price's `cost_amount`, interval-normalized). + * This is MRR after license/COGS — the recurring contribution margin. + */ + public function netMrr(): int + { + return $this->subscriptionMetrics()['net_mrr']; + } + + /** + * Normalize a recurring price's `unit_amount` to a monthly run-rate in cents + * for `$quantity` units. Null for a non-recurring (one-time) price. */ protected function priceMonthlyAmount(ProductPrice $price, int $quantity = 1): ?int + { + return $this->monthlyForPrice($price, (float) $price->unit_amount, $quantity); + } + + /** + * Normalize a recurring price's `cost_amount` (COGS — license/shipping/etc.) + * to a monthly figure in cents for `$quantity` units. Null when the price is + * one-time; 0 when it carries no cost basis. + */ + protected function priceMonthlyCost(ProductPrice $price, int $quantity = 1): ?int + { + return $this->monthlyForPrice($price, (float) ($price->cost_amount ?? 0), $quantity); + } + + /** + * Shared interval normalization: turn `$amountCents` charged/incurred once + * per the price's billing period into a monthly run-rate for `$quantity` + * units. Weekly and daily use the Gregorian average-days-per-month constant + * so they line up with Cashier/Stripe's MRR normalization. Returns null for + * a non-recurring (one-time) price. + */ + protected function monthlyForPrice(ProductPrice $price, float $amountCents, 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 + return null; // one-time price — not recurring } - $amount = (float) $price->unit_amount * max(1, $quantity); + $amount = $amountCents * max(1, $quantity); $count = max(1, (int) ($price->interval_count ?: 1)); $daysPerMonth = 30.436875; // Gregorian average month length diff --git a/tests/Feature/Subscriptions/MrrMetricsTest.php b/tests/Feature/Subscriptions/MrrMetricsTest.php index 398a1b9..fd6ba34 100644 --- a/tests/Feature/Subscriptions/MrrMetricsTest.php +++ b/tests/Feature/Subscriptions/MrrMetricsTest.php @@ -46,7 +46,7 @@ class MrrMetricsTest extends TestCase ]); } - private function price(Product $product, string $stripePrice, int $unitAmount, RecurringInterval|string|null $interval, int $intervalCount = 1): ProductPrice + private function price(Product $product, string $stripePrice, int $unitAmount, RecurringInterval|string|null $interval, int $intervalCount = 1, int $costAmount = 0): ProductPrice { return ProductPrice::create([ 'purchasable_type' => Product::class, @@ -55,6 +55,7 @@ class MrrMetricsTest extends TestCase 'type' => $interval === null ? PriceType::ONE_TIME : PriceType::RECURRING, 'currency' => 'EUR', 'unit_amount' => $unitAmount, + 'cost_amount' => $costAmount, 'is_default' => true, 'active' => true, 'interval' => $interval, @@ -140,6 +141,29 @@ class MrrMetricsTest extends TestCase $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_splits_mrr_by_product(): void {