feat(cost): per-price cost basis (COGS) + net-MRR margin

Add ProductPrice.cost_amount (integer cents) — the cost to deliver one unit at
that price: manufacturing, shipping, a content license or royalty; per billing
period for recurring prices. Shop now reports margin natively: subscriptionMetrics()
returns `cogs` + `net_mrr`, and Shop::netMrr() gives MRR after cost of goods.

Additive: cost_amount defaults to 0, so existing consumers are unchanged.
Covered by MrrMetricsTest; full suite green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Fabian @ Blax Software 2026-08-28 15:12:42 +02:00
parent f6f8916bed
commit 9e3e4b2270
5 changed files with 106 additions and 8 deletions

View File

@ -0,0 +1,36 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Cost basis (COGS) per unit sold at a price: what it costs us to deliver one
* unit — manufacturing, shipping, a content license or royalty. For a recurring
* price it is the cost per billing period. Lets the shop report gross margin and
* net MRR (revenue − cost) instead of top-line only. Integer cents; 0 = no cost.
*/
return new class extends Migration
{
public function up(): void
{
$table = config('shop.tables.product_prices', 'product_prices');
if (Schema::hasTable($table) && ! Schema::hasColumn($table, 'cost_amount')) {
Schema::table($table, function (Blueprint $t) {
$t->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');
});
}
}
};

View File

@ -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()
*

View File

@ -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',
];

View File

@ -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

View File

@ -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
{