fix(billing): upgrade or downgrade is decided by rank, not by price
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
parent
ac250071cb
commit
460fac01b1
|
|
@ -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),
|
||||
];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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'];
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue