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:
parent
9e3e4b2270
commit
47e7300e98
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -60,6 +60,8 @@ class ProductPrice extends Model implements Cartable
|
|||
'unit_amount',
|
||||
'sale_unit_amount',
|
||||
'cost_amount',
|
||||
'license_percent',
|
||||
'license_min_amount',
|
||||
'is_default',
|
||||
'active',
|
||||
'billing_scheme',
|
||||
|
|
@ -79,6 +81,8 @@ class ProductPrice extends Model implements Cartable
|
|||
'unit_amount' => 'float',
|
||||
'sale_unit_amount' => 'float',
|
||||
'cost_amount' => 'float',
|
||||
'license_percent' => 'float',
|
||||
'license_min_amount' => 'integer',
|
||||
'interval_count' => 'integer',
|
||||
'trial_period_days' => 'integer',
|
||||
];
|
||||
|
|
@ -119,6 +123,31 @@ class ProductPrice extends Model implements Cartable
|
|||
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
|
||||
* when this is an unconditional (standalone) price.
|
||||
|
|
|
|||
|
|
@ -609,6 +609,7 @@ class ShopService
|
|||
|
||||
$mrr = 0.0;
|
||||
$cogs = 0.0;
|
||||
$license = 0.0;
|
||||
$byProduct = [];
|
||||
$unpricedItems = 0;
|
||||
|
||||
|
|
@ -633,6 +634,7 @@ class ShopService
|
|||
|
||||
$mrr += $monthly;
|
||||
$cogs += $this->priceMonthlyCost($price, $quantity) ?? 0;
|
||||
$license += $this->priceMonthlyLicense($price, $quantity) ?? 0;
|
||||
$slug = $this->priceProductSlug($price)
|
||||
?? $this->subscriptionProductSlug($subscription)
|
||||
?? 'unknown';
|
||||
|
|
@ -646,16 +648,18 @@ class ShopService
|
|||
'subscribers' => $subscriptions->count(),
|
||||
'mrr' => (int) round($mrr),
|
||||
'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),
|
||||
'unpriced_items' => $unpricedItems,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Net Monthly Recurring Revenue in cents: {@see mrr()} minus recurring cost
|
||||
* of goods (`cost_amount`) minus the amortized royalty / minimum licence fee
|
||||
* ({@see priceMonthlyLicense()}). The recurring contribution margin — MRR
|
||||
* after everything it costs to deliver the subscription.
|
||||
*/
|
||||
public function netMrr(): int
|
||||
{
|
||||
|
|
@ -714,6 +718,61 @@ class ShopService
|
|||
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
|
||||
* null when the purchasable is absent or has no slug/name.
|
||||
|
|
|
|||
|
|
@ -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([
|
||||
'purchasable_type' => Product::class,
|
||||
|
|
@ -56,6 +56,8 @@ class MrrMetricsTest extends TestCase
|
|||
'currency' => 'EUR',
|
||||
'unit_amount' => $unitAmount,
|
||||
'cost_amount' => $costAmount,
|
||||
'license_percent' => $licensePercent,
|
||||
'license_min_amount' => $licenseMin ?: null,
|
||||
'is_default' => true,
|
||||
'active' => true,
|
||||
'interval' => $interval,
|
||||
|
|
@ -164,6 +166,39 @@ class MrrMetricsTest extends TestCase
|
|||
$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
|
||||
{
|
||||
|
|
|
|||
Loading…
Reference in New Issue