fix(billing): upgrade or downgrade is decided by rank, not by price
tests / pest (push) Successful in 6m51s Details
tests / assets (push) Successful in 20s Details
tests / release (push) Has been skipped Details

With grandfathered prices the comparison inverts: a business plan bought when
it cost less than today's team plan would have treated a move to team as an
upgrade — charged immediately, while the customer loses resources. The plan's
rank is frozen with the rest of the snapshot and decides the direction; prices
only decide the amount.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
feat/portal-design
nexxo 2026-07-26 09:52:28 +02:00
parent ac250071cb
commit 460fac01b1
5 changed files with 69 additions and 6 deletions

View File

@ -24,7 +24,7 @@ class Subscription extends Model
*/
public const FROZEN = [
'plan', 'term', 'price_cents', 'currency', 'quota_gb', 'traffic_gb',
'seats', 'ram_mb', 'cores', 'disk_gb', 'performance', 'started_at',
'seats', 'ram_mb', 'cores', 'disk_gb', 'performance', 'tier', 'started_at',
];
protected $guarded = [];
@ -53,6 +53,7 @@ class Subscription extends Model
'ram_mb' => 'integer',
'cores' => 'integer',
'disk_gb' => 'integer',
'tier' => 'integer',
'started_at' => 'datetime',
'current_period_start' => 'datetime',
'current_period_end' => 'datetime',
@ -101,6 +102,9 @@ class Subscription extends Model
'cores' => (int) ($catalogue['cores'] ?? 0),
'disk_gb' => (int) ($catalogue['disk_gb'] ?? 0),
'performance' => $catalogue['performance'] ?? null,
// Frozen too: which direction a later change goes must not depend
// on where the plan sits in today's catalogue.
'tier' => (int) ($catalogue['tier'] ?? 0),
];
}

View File

@ -38,7 +38,11 @@ final readonly class PlanChange
$at = $at?->copy() ?? now();
$target = Subscription::snapshotFrom($targetPlan, $subscription->term);
$isUpgrade = $target['price_cents'] > $subscription->price_cents;
// Direction comes from the plan's rank, never from the prices: a
// grandfathered business plan can cost less than today's team plan, and
// comparing those would call losing resources an "upgrade" and charge
// for it immediately. Prices decide the amount, not the direction.
$isUpgrade = (int) $target['tier'] > (int) $subscription->tier;
// Whole days, and never zero: on the last day of a term there is still
// a day of service left to charge or refund.
@ -88,6 +92,11 @@ final readonly class PlanChange
throw new RuntimeException('An upgrade is charged, not credited.');
}
// A sideways move at the same rank has no difference to credit.
if ((int) Subscription::snapshotFrom($targetPlan, $subscription->term)['tier'] === (int) $subscription->tier) {
return 0;
}
$target = Subscription::snapshotFrom($targetPlan, $subscription->term);
$difference = $subscription->price_cents - $target['price_cents'];

View File

@ -67,10 +67,10 @@ return [
// ADMIN-ONLY. Customers compare seats, storage, performance class and
// features — never raw vCPU/RAM (see docs/specs/2026-07-25-portal-d-*).
'plans' => [
'start' => ['traffic_gb' => 1000, 'quota_gb' => 100, 'disk_gb' => 120, 'ram_mb' => 4096, 'cores' => 2, 'seats' => 5, 'performance' => 'standard', 'price_cents' => 4900, 'template_vmid' => 9000],
'team' => ['traffic_gb' => 3000, 'quota_gb' => 500, 'disk_gb' => 540, 'ram_mb' => 8192, 'cores' => 4, 'seats' => 25, 'performance' => 'enhanced', 'price_cents' => 17900, 'template_vmid' => 9000],
'business' => ['traffic_gb' => 8000, 'quota_gb' => 1000, 'disk_gb' => 1050, 'ram_mb' => 16384, 'cores' => 8, 'seats' => 100, 'performance' => 'high', 'price_cents' => 39900, 'template_vmid' => 9000],
'enterprise' => ['traffic_gb' => 20000, 'quota_gb' => 2000, 'disk_gb' => 2100, 'ram_mb' => 32768, 'cores' => 16, 'seats' => 500, 'performance' => 'dedicated', 'price_cents' => 79900, 'template_vmid' => 9000],
'start' => ['tier' => 1, 'traffic_gb' => 1000, 'quota_gb' => 100, 'disk_gb' => 120, 'ram_mb' => 4096, 'cores' => 2, 'seats' => 5, 'performance' => 'standard', 'price_cents' => 4900, 'template_vmid' => 9000],
'team' => ['tier' => 2, 'traffic_gb' => 3000, 'quota_gb' => 500, 'disk_gb' => 540, 'ram_mb' => 8192, 'cores' => 4, 'seats' => 25, 'performance' => 'enhanced', 'price_cents' => 17900, 'template_vmid' => 9000],
'business' => ['tier' => 3, 'traffic_gb' => 8000, 'quota_gb' => 1000, 'disk_gb' => 1050, 'ram_mb' => 16384, 'cores' => 8, 'seats' => 100, 'performance' => 'high', 'price_cents' => 39900, 'template_vmid' => 9000],
'enterprise' => ['tier' => 4, 'traffic_gb' => 20000, 'quota_gb' => 2000, 'disk_gb' => 2100, 'ram_mb' => 32768, 'cores' => 16, 'seats' => 500, 'performance' => 'dedicated', 'price_cents' => 79900, 'template_vmid' => 9000],
],
// Default branding applied when a customer has not set their own (resolved by

View File

@ -0,0 +1,29 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* The plan's rank, frozen with the rest of the snapshot.
*
* Which direction a later plan change goes must not be read from prices: a
* grandfathered plan can cost less than a smaller plan does today, and
* comparing those would charge a customer immediately for losing resources.
*/
return new class extends Migration
{
public function up(): void
{
Schema::table('subscriptions', function (Blueprint $table) {
$table->unsignedTinyInteger('tier')->default(0)->after('plan');
});
}
public function down(): void
{
Schema::table('subscriptions', function (Blueprint $table) {
$table->dropColumn('tier');
});
}
};

View File

@ -123,3 +123,24 @@ it('prices a yearly term as twelve months of the same plan', function () {
// Any discount belongs in the catalogue, not hidden in the conversion.
expect($yearly['price_cents'])->toBe($monthly['price_cents'] * 12);
});
it('reads the direction from the plan rank, not from a grandfathered price', function () {
$start = Illuminate\Support\Carbon::parse('2026-08-01');
// A business customer from back when business cost less than today's team.
$subscription = Subscription::factory()->plan('business')->create([
'current_period_start' => $start,
'current_period_end' => $start->copy()->addMonth(),
]);
$subscription->forceFill(['price_cents' => 9900])->saveQuietly(); // grandfathered
config()->set('provisioning.plans.team.price_cents', 17900); // today's team costs more
$change = PlanChange::evaluate($subscription->fresh(), 'team', Illuminate\Support\Carbon::parse('2026-08-15'));
// Moving to team takes resources away — that is a downgrade whatever the
// prices say, and it waits for the end of the term.
expect($change->isUpgrade)->toBeFalse()
->and($change->allowedNow)->toBeFalse()
->and($change->chargeCents)->toBe(0);
});