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
parent
c36ea17b39
commit
ac250071cb
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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(),
|
||||
]);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
};
|
||||
|
|
@ -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);
|
||||
});
|
||||
Loading…
Reference in New Issue