feat(billing): immutable subscription snapshots and pro-rata plan changes

The plan catalogue describes what we sell today; a customer who signed up last
year bought last year's terms. Every commercially relevant condition — price,
quotas, seats, the hardware behind the plan — is now frozen onto a subscription
at signup, and the model refuses to let any of it be rewritten afterwards. A
price rise applies to new subscriptions and cannot reach back into an existing
contract.

PlanChange holds the two rules, computed against the frozen price rather than
today's catalogue:

- Upgrading is immediate and pro rata: the new plan for the days left in the
  paid term, minus what the old plan was worth over those same days. On the
  last day of a month that is one day's difference, not a month's.
- Downgrading waits for the end of the term. A yearly customer bought a year
  and can move down when it is up; a month is a month. A mid-term downgrade is
  a goodwill decision, not a self-service button, and its credit covers only
  the unused part of the difference.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
feat/portal-design
nexxo 2026-07-26 09:49:48 +02:00
parent c36ea17b39
commit ac250071cb
5 changed files with 436 additions and 0 deletions

111
app/Models/Subscription.php Normal file
View File

@ -0,0 +1,111 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use RuntimeException;
class Subscription extends Model
{
use HasFactory;
use HasUuids;
public const TERM_MONTHLY = 'monthly';
public const TERM_YEARLY = 'yearly';
/**
* The conditions the customer is owed. Changing any of these after the fact
* would rewrite a contract, so the model refuses a price rise applies to
* new subscriptions, never to existing ones.
*/
public const FROZEN = [
'plan', 'term', 'price_cents', 'currency', 'quota_gb', 'traffic_gb',
'seats', 'ram_mb', 'cores', 'disk_gb', 'performance', 'started_at',
];
protected $guarded = [];
protected static function booted(): void
{
static::updating(function (self $subscription) {
$frozen = array_intersect(array_keys($subscription->getDirty()), self::FROZEN);
if ($frozen !== []) {
throw new RuntimeException(
'A subscription snapshot is immutable; tried to change: '.implode(', ', $frozen).
'. Cancel this subscription and start a new one instead.'
);
}
});
}
protected function casts(): array
{
return [
'price_cents' => 'integer',
'quota_gb' => 'integer',
'traffic_gb' => 'integer',
'seats' => 'integer',
'ram_mb' => 'integer',
'cores' => 'integer',
'disk_gb' => 'integer',
'started_at' => 'datetime',
'current_period_start' => 'datetime',
'current_period_end' => 'datetime',
'pending_effective_at' => 'datetime',
'cancelled_at' => 'datetime',
];
}
public function uniqueIds(): array
{
return ['uuid'];
}
public function customer(): BelongsTo
{
return $this->belongsTo(Customer::class);
}
public function instance(): BelongsTo
{
return $this->belongsTo(Instance::class);
}
/** Take today's catalogue entry and freeze it onto a new subscription. */
public static function snapshotFrom(string $plan, string $term = self::TERM_MONTHLY): array
{
$catalogue = (array) config("provisioning.plans.{$plan}");
if ($catalogue === []) {
throw new RuntimeException("Unknown plan: {$plan}");
}
$monthly = (int) ($catalogue['price_cents'] ?? 0);
return [
'plan' => $plan,
'term' => $term,
// A yearly term is twelve months of the same price. Any discount
// belongs in the catalogue, not hidden in this conversion.
'price_cents' => $term === self::TERM_YEARLY ? $monthly * 12 : $monthly,
'currency' => 'EUR',
'quota_gb' => (int) ($catalogue['quota_gb'] ?? 0),
'traffic_gb' => (int) ($catalogue['traffic_gb'] ?? 0),
'seats' => (int) ($catalogue['seats'] ?? 0),
'ram_mb' => (int) ($catalogue['ram_mb'] ?? 0),
'cores' => (int) ($catalogue['cores'] ?? 0),
'disk_gb' => (int) ($catalogue['disk_gb'] ?? 0),
'performance' => $catalogue['performance'] ?? null,
];
}
public function isYearly(): bool
{
return $this->term === self::TERM_YEARLY;
}
}

View File

@ -0,0 +1,98 @@
<?php
namespace App\Services\Billing;
use App\Models\Subscription;
use Illuminate\Support\Carbon;
use RuntimeException;
/**
* What a plan change costs, and whether it is allowed yet.
*
* Two rules, both the customer's rules:
*
* - **Upgrading is immediate and pro rata.** They get the bigger plan now and
* pay only for the days left in the term they already paid for, minus what
* the old plan was worth over those same days.
* - **Downgrading waits for the end of the term.** Someone on a yearly
* contract bought a year; they can move down when that year is up, not in
* month three. A month is a month.
*
* Everything is computed against the SUBSCRIPTION's frozen price, never against
* today's catalogue that is the whole point of the snapshot.
*/
final readonly class PlanChange
{
public function __construct(
public bool $isUpgrade,
public bool $allowedNow,
public int $chargeCents,
public int $creditCents,
public ?Carbon $effectiveAt,
public int $remainingDays,
public int $termDays,
) {}
public static function evaluate(Subscription $subscription, string $targetPlan, ?Carbon $at = null): self
{
$at = $at?->copy() ?? now();
$target = Subscription::snapshotFrom($targetPlan, $subscription->term);
$isUpgrade = $target['price_cents'] > $subscription->price_cents;
// Whole days, and never zero: on the last day of a term there is still
// a day of service left to charge or refund.
$termDays = max(1, (int) $subscription->current_period_start->diffInDays($subscription->current_period_end));
$remainingDays = (int) max(0, floor($at->diffInDays($subscription->current_period_end, absolute: false)));
if (! $isUpgrade) {
// Downgrade: nothing changes and nothing is owed until the term ends.
return new self(
isUpgrade: false,
allowedNow: false,
chargeCents: 0,
creditCents: 0,
effectiveAt: $subscription->current_period_end->copy(),
remainingDays: $remainingDays,
termDays: $termDays,
);
}
// Pro rata on both sides: the unused part of what they already paid is
// worth something, and only the difference is charged.
$unusedOld = (int) round($subscription->price_cents * $remainingDays / $termDays);
$newForRest = (int) round($target['price_cents'] * $remainingDays / $termDays);
return new self(
isUpgrade: true,
allowedNow: true,
chargeCents: max(0, $newForRest - $unusedOld),
creditCents: 0,
effectiveAt: $at,
remainingDays: $remainingDays,
termDays: $termDays,
);
}
/**
* What a mid-term downgrade would be worth, for the cases where we grant one
* anyway a goodwill move or a support decision. Never offered to the
* customer as a self-service button, because a downgrade before the term is
* up is a change to the contract, not a setting.
*/
public static function goodwillCredit(Subscription $subscription, string $targetPlan, ?Carbon $at = null): int
{
$change = self::evaluate($subscription, $targetPlan, $at);
if ($change->isUpgrade) {
throw new RuntimeException('An upgrade is charged, not credited.');
}
$target = Subscription::snapshotFrom($targetPlan, $subscription->term);
$difference = $subscription->price_cents - $target['price_cents'];
// Only the unused part of the difference: they used the expensive plan
// for the days that have passed.
return (int) round($difference * $change->remainingDays / $change->termDays);
}
}

View File

@ -0,0 +1,37 @@
<?php
namespace Database\Factories;
use App\Models\Customer;
use App\Models\Subscription;
use Illuminate\Database\Eloquent\Factories\Factory;
class SubscriptionFactory extends Factory
{
public function definition(): array
{
$start = now()->startOfMonth();
return array_merge(Subscription::snapshotFrom('team'), [
'customer_id' => Customer::factory(),
'started_at' => $start,
'current_period_start' => $start,
'current_period_end' => $start->copy()->addMonth(),
'status' => 'active',
]);
}
public function plan(string $plan, string $term = Subscription::TERM_MONTHLY): static
{
return $this->state(function () use ($plan, $term) {
$start = now()->startOfMonth();
return array_merge(Subscription::snapshotFrom($plan, $term), [
'current_period_start' => $start,
'current_period_end' => $term === Subscription::TERM_YEARLY
? $start->copy()->addYear()
: $start->copy()->addMonth(),
]);
});
}
}

View File

@ -0,0 +1,65 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* What a customer actually bought, frozen at the moment they bought it.
*
* The plan catalogue in config/provisioning.php describes what we sell TODAY.
* A customer who signed up last year bought last year's terms, and raising a
* price must never reach back into their contract. So every condition that
* matters commercially is copied here at signup and never touched again:
* price, quotas, seats, the hardware behind the plan.
*
* `term` decides the rhythm: a monthly subscription can be changed at the end
* of the month, a yearly one at the end of the year. Upgrades are the exception
* they take effect immediately and are charged pro rata.
*/
return new class extends Migration
{
public function up(): void
{
Schema::create('subscriptions', function (Blueprint $table) {
$table->id();
$table->uuid()->unique();
$table->foreignId('customer_id')->constrained()->cascadeOnDelete();
$table->foreignId('instance_id')->nullable()->constrained()->nullOnDelete();
$table->string('plan');
$table->string('term')->default('monthly'); // monthly|yearly
// ── Snapshot: the conditions this customer is owed ──────────────
$table->unsignedInteger('price_cents'); // net, per term
$table->string('currency', 3)->default('EUR');
$table->unsignedBigInteger('quota_gb');
$table->unsignedBigInteger('traffic_gb');
$table->unsignedInteger('seats');
$table->unsignedInteger('ram_mb');
$table->unsignedInteger('cores');
$table->unsignedBigInteger('disk_gb');
$table->string('performance')->nullable();
$table->timestamp('started_at');
$table->timestamp('current_period_start');
$table->timestamp('current_period_end');
// A downgrade is scheduled, never immediate: it lands when the term
// the customer already paid for runs out.
$table->string('pending_plan')->nullable();
$table->timestamp('pending_effective_at')->nullable();
$table->string('status')->default('active'); // active|cancelled
$table->timestamp('cancelled_at')->nullable();
$table->timestamps();
$table->index(['customer_id', 'status']);
});
}
public function down(): void
{
Schema::dropIfExists('subscriptions');
}
};

View File

@ -0,0 +1,125 @@
<?php
use App\Models\Subscription;
use App\Services\Billing\PlanChange;
use Illuminate\Support\Carbon;
it('freezes what the customer bought against later price rises', function () {
$subscription = Subscription::factory()->plan('team')->create();
$bookedPrice = $subscription->price_cents;
config()->set('provisioning.plans.team.price_cents', $bookedPrice * 2);
// The existing customer keeps their terms — that is the entire point.
expect($subscription->fresh()->price_cents)->toBe($bookedPrice)
->and(Subscription::snapshotFrom('team')['price_cents'])->toBe($bookedPrice * 2);
});
it('refuses to rewrite a snapshot after the fact', function () {
$subscription = Subscription::factory()->plan('team')->create();
expect(fn () => $subscription->update(['price_cents' => 1]))
->toThrow(RuntimeException::class, 'immutable');
expect($subscription->fresh()->price_cents)->toBe(Subscription::snapshotFrom('team')['price_cents']);
// The rejected value is still on the in-memory model, so it has to be
// discarded before anything else can be saved — otherwise the next write
// carries the forbidden change along and is refused too.
$subscription->refresh();
// Operational fields still move.
$subscription->update(['pending_plan' => 'start']);
expect($subscription->fresh()->pending_plan)->toBe('start');
});
it('charges only the remaining days when upgrading mid-month', function () {
$start = Carbon::parse('2026-08-01');
$subscription = Subscription::factory()->plan('start')->create([
'current_period_start' => $start,
'current_period_end' => $start->copy()->addMonth(),
]);
$change = PlanChange::evaluate($subscription, 'team', Carbon::parse('2026-08-16'));
$startPrice = Subscription::snapshotFrom('start')['price_cents'];
$teamPrice = Subscription::snapshotFrom('team')['price_cents'];
$expected = (int) round($teamPrice * 16 / 31) - (int) round($startPrice * 16 / 31);
expect($change->isUpgrade)->toBeTrue()
->and($change->allowedNow)->toBeTrue()
->and($change->chargeCents)->toBe($expected)
->and($change->chargeCents)->toBeLessThan($teamPrice)
->and($change->chargeCents)->toBeGreaterThan(0);
});
it('makes an upgrade on the last day cost almost nothing', function () {
$start = Carbon::parse('2026-08-01');
$subscription = Subscription::factory()->plan('start')->create([
'current_period_start' => $start,
'current_period_end' => $start->copy()->addMonth(),
]);
$change = PlanChange::evaluate($subscription, 'business', Carbon::parse('2026-08-31'));
// One day of the difference, not a month of it.
expect($change->remainingDays)->toBe(1)
->and($change->chargeCents)->toBeLessThan(2000);
});
it('holds a downgrade until the term the customer paid for is over', function () {
$start = Carbon::parse('2026-01-01');
$yearly = Subscription::factory()->plan('business', Subscription::TERM_YEARLY)->create([
'current_period_start' => $start,
'current_period_end' => $start->copy()->addYear(),
]);
$change = PlanChange::evaluate($yearly, 'start', Carbon::parse('2026-03-15'));
// Someone on a yearly contract bought a year, not three months.
expect($change->isUpgrade)->toBeFalse()
->and($change->allowedNow)->toBeFalse()
->and($change->chargeCents)->toBe(0)
->and($change->effectiveAt->toDateString())->toBe('2027-01-01');
});
it('lets a monthly customer move down at the end of the month', function () {
$start = Carbon::parse('2026-08-01');
$monthly = Subscription::factory()->plan('business')->create([
'current_period_start' => $start,
'current_period_end' => $start->copy()->addMonth(),
]);
expect(PlanChange::evaluate($monthly, 'team', Carbon::parse('2026-08-10'))->effectiveAt->toDateString())
->toBe('2026-09-01');
});
it('credits only the unused part of the difference for a goodwill downgrade', function () {
$start = Carbon::parse('2026-08-01');
$subscription = Subscription::factory()->plan('business')->create([
'current_period_start' => $start,
'current_period_end' => $start->copy()->addMonth(),
]);
// 11 days left of 31: they used the expensive plan for the other 20.
$credit = PlanChange::goodwillCredit($subscription, 'team', Carbon::parse('2026-08-21'));
$difference = Subscription::snapshotFrom('business')['price_cents'] - Subscription::snapshotFrom('team')['price_cents'];
expect($credit)->toBe((int) round($difference * 11 / 31))
->and($credit)->toBeLessThan($difference);
});
it('will not call an upgrade a credit', function () {
$subscription = Subscription::factory()->plan('start')->create();
expect(fn () => PlanChange::goodwillCredit($subscription, 'business'))
->toThrow(RuntimeException::class);
});
it('prices a yearly term as twelve months of the same plan', function () {
$monthly = Subscription::snapshotFrom('team');
$yearly = Subscription::snapshotFrom('team', Subscription::TERM_YEARLY);
// Any discount belongs in the catalogue, not hidden in the conversion.
expect($yearly['price_cents'])->toBe($monthly['price_cents'] * 12);
});