feat(cost): royalty / minimum-licence-fee margin (Aircademy-style)

Add ProductPrice.license_percent + license_min_amount and
ProductPrice::licenseFeePerPeriod() = max(percent% of unit_amount, minimum).
The shop amortizes it over the licence-term bracket (<=quarter / 6mo / 12mo),
so a monthly subscriber billed 3x in a quarter costs ONE quarterly fee — the
faithful reading of a per-unit, quarterly-accounted content licence.
subscriptionMetrics() now returns license_cost and net_mrr nets it out.

Additive: a null rule contributes 0. 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 16:03:04 +02:00
parent 9e3e4b2270
commit 47e7300e98
4 changed files with 174 additions and 5 deletions

View File

@ -0,0 +1,46 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Royalty / minimum-licence-fee rule for a price (e.g. an Aircademy-style
* content licence): the fee owed per unit sold is
* max(license_percent% of unit_amount, license_min_amount)
* accounted per licence-term bracket (1–3mo / 6mo / 12mo). Lets the shop compute
* a faithful net margin instead of a flat per-cycle cost. Null = no royalty.
*/
return new class extends Migration
{
public function up(): void
{
$table = config('shop.tables.product_prices', 'product_prices');
if (! Schema::hasTable($table)) {
return;
}
Schema::table($table, function (Blueprint $t) use ($table) {
if (! Schema::hasColumn($table, 'license_percent')) {
$t->decimal('license_percent', 5, 2)->nullable()->after('cost_amount');
}
if (! Schema::hasColumn($table, 'license_min_amount')) {
$t->integer('license_min_amount')->nullable()->after('license_percent');
}
});
}
public function down(): void
{
$table = config('shop.tables.product_prices', 'product_prices');
Schema::table($table, function (Blueprint $t) use ($table) {
foreach (['license_percent', 'license_min_amount'] as $col) {
if (Schema::hasColumn($table, $col)) {
$t->dropColumn($col);
}
}
});
}
};

View File

@ -60,6 +60,8 @@ class ProductPrice extends Model implements Cartable
'unit_amount', 'unit_amount',
'sale_unit_amount', 'sale_unit_amount',
'cost_amount', 'cost_amount',
'license_percent',
'license_min_amount',
'is_default', 'is_default',
'active', 'active',
'billing_scheme', 'billing_scheme',
@ -79,6 +81,8 @@ class ProductPrice extends Model implements Cartable
'unit_amount' => 'float', 'unit_amount' => 'float',
'sale_unit_amount' => 'float', 'sale_unit_amount' => 'float',
'cost_amount' => 'float', 'cost_amount' => 'float',
'license_percent' => 'float',
'license_min_amount' => 'integer',
'interval_count' => 'integer', 'interval_count' => 'integer',
'trial_period_days' => 'integer', 'trial_period_days' => 'integer',
]; ];
@ -119,6 +123,31 @@ class ProductPrice extends Model implements Cartable
return $this->unit_amount; return $this->unit_amount;
} }
/**
* The royalty / minimum licence fee owed for ONE unit sold at this price for
* ONE licence-term period (cents): `max(license_percent% of unit_amount,
* license_min_amount)`. Follows the Aircademy-style rule — a percentage of
* the net list price, floored at a term minimum. 0 when the price carries no
* licence rule. Amortized to a run-rate by the caller (see the shop's
* subscription metrics), so it is charged once per term, not per billing
* cycle.
*/
public function licenseFeePerPeriod(): int
{
$percent = $this->license_percent;
$min = (int) ($this->license_min_amount ?? 0);
if ($percent === null && $min === 0) {
return 0;
}
$percentFee = $percent !== null
? (int) round(((float) $percent / 100) * (float) $this->unit_amount)
: 0;
return max($percentFee, $min);
}
/** /**
* The conditional-pricing requirement carried in `meta.requires`, or null * The conditional-pricing requirement carried in `meta.requires`, or null
* when this is an unconditional (standalone) price. * when this is an unconditional (standalone) price.

View File

@ -609,6 +609,7 @@ class ShopService
$mrr = 0.0; $mrr = 0.0;
$cogs = 0.0; $cogs = 0.0;
$license = 0.0;
$byProduct = []; $byProduct = [];
$unpricedItems = 0; $unpricedItems = 0;
@ -633,6 +634,7 @@ class ShopService
$mrr += $monthly; $mrr += $monthly;
$cogs += $this->priceMonthlyCost($price, $quantity) ?? 0; $cogs += $this->priceMonthlyCost($price, $quantity) ?? 0;
$license += $this->priceMonthlyLicense($price, $quantity) ?? 0;
$slug = $this->priceProductSlug($price) $slug = $this->priceProductSlug($price)
?? $this->subscriptionProductSlug($subscription) ?? $this->subscriptionProductSlug($subscription)
?? 'unknown'; ?? 'unknown';
@ -646,16 +648,18 @@ class ShopService
'subscribers' => $subscriptions->count(), 'subscribers' => $subscriptions->count(),
'mrr' => (int) round($mrr), 'mrr' => (int) round($mrr),
'cogs' => (int) round($cogs), 'cogs' => (int) round($cogs),
'net_mrr' => (int) round($mrr - $cogs), 'license_cost' => (int) round($license),
'net_mrr' => (int) round($mrr - $cogs - $license),
'by_product' => array_map(static fn ($cents) => (int) round($cents), $byProduct), 'by_product' => array_map(static fn ($cents) => (int) round($cents), $byProduct),
'unpriced_items' => $unpricedItems, 'unpriced_items' => $unpricedItems,
]; ];
} }
/** /**
* Net Monthly Recurring Revenue in cents: {@see mrr()} minus the recurring * Net Monthly Recurring Revenue in cents: {@see mrr()} minus recurring cost
* cost of goods (each active price's `cost_amount`, interval-normalized). * of goods (`cost_amount`) minus the amortized royalty / minimum licence fee
* This is MRR after license/COGS — the recurring contribution margin. * ({@see priceMonthlyLicense()}). The recurring contribution margin — MRR
* after everything it costs to deliver the subscription.
*/ */
public function netMrr(): int public function netMrr(): int
{ {
@ -714,6 +718,61 @@ class ShopService
return $monthly === null ? null : (int) round($monthly); return $monthly === null ? null : (int) round($monthly);
} }
/**
* Monthly run-rate (cents) of the royalty / minimum licence fee for a
* recurring price. The fee ({@see ProductPrice::licenseFeePerPeriod()}) is
* owed once per licence-term bracket — 1–3 months, 6 months, or 12 months —
* so it is amortized over that bracket, NOT per billing cycle. That is why a
* monthly subscriber renewing three times in a quarter still costs one
* quarterly fee: a €25.50/mo Full Seat with a €15 minimum contributes €15 ÷
* 3 = €5/month. Null for a non-recurring price; 0 when there is no rule.
*/
protected function priceMonthlyLicense(ProductPrice $price, 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 — subscription licence run-rate N/A
}
$perPeriod = $price->licenseFeePerPeriod();
if ($perPeriod <= 0) {
return 0;
}
return (int) round($perPeriod * max(1, $quantity) / $this->licenseBracketMonths($price));
}
/**
* The licence-term bracket a price falls in, in months: its term (interval ×
* interval_count) snapped to 3 (the ≤ quarter accounting bracket), 6, or 12.
* The quarterly accounting period is the smallest bracket, so any
* sub-quarterly billing (monthly/weekly) amortizes over 3 months.
*/
protected function licenseBracketMonths(ProductPrice $price): int
{
$interval = $price->interval instanceof RecurringInterval
? $price->interval->value
: (is_string($price->interval) ? $price->interval : null);
$count = max(1, (int) ($price->interval_count ?: 1));
$daysPerMonth = 30.436875;
$termMonths = match ($interval) {
'year' => 12 * $count,
'quarter' => 3 * $count,
'month' => $count,
'week' => $count * 7 / $daysPerMonth,
'day' => $count / $daysPerMonth,
default => 1,
};
return $termMonths <= 3 ? 3 : ($termMonths <= 6 ? 6 : 12);
}
/** /**
* Product slug carried by a price's purchasable (usually a Product), or * Product slug carried by a price's purchasable (usually a Product), or
* null when the purchasable is absent or has no slug/name. * null when the purchasable is absent or has no slug/name.

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, int $costAmount = 0): ProductPrice 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([ return ProductPrice::create([
'purchasable_type' => Product::class, 'purchasable_type' => Product::class,
@ -56,6 +56,8 @@ class MrrMetricsTest extends TestCase
'currency' => 'EUR', 'currency' => 'EUR',
'unit_amount' => $unitAmount, 'unit_amount' => $unitAmount,
'cost_amount' => $costAmount, 'cost_amount' => $costAmount,
'license_percent' => $licensePercent,
'license_min_amount' => $licenseMin ?: null,
'is_default' => true, 'is_default' => true,
'active' => true, 'active' => true,
'interval' => $interval, 'interval' => $interval,
@ -164,6 +166,39 @@ class MrrMetricsTest extends TestCase
$this->assertSame($expectedMrr - $expectedCogs, Shop::netMrr()); $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] #[Test]
public function it_splits_mrr_by_product(): void public function it_splits_mrr_by_product(): void
{ {