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:
parent
f6f8916bed
commit
9e3e4b2270
|
|
@ -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');
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
@ -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()
|
||||
*
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
{
|
||||
|
|
|
|||
Loading…
Reference in New Issue