diff --git a/app/Models/Subscription.php b/app/Models/Subscription.php index d323f0a..5b654bf 100644 --- a/app/Models/Subscription.php +++ b/app/Models/Subscription.php @@ -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), ]; } diff --git a/app/Services/Billing/PlanChange.php b/app/Services/Billing/PlanChange.php index 164f256..fbfeb1a 100644 --- a/app/Services/Billing/PlanChange.php +++ b/app/Services/Billing/PlanChange.php @@ -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']; diff --git a/config/provisioning.php b/config/provisioning.php index 6a34ad1..2faca93 100644 --- a/config/provisioning.php +++ b/config/provisioning.php @@ -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 diff --git a/database/migrations/2026_07_26_020001_add_tier_to_subscriptions.php b/database/migrations/2026_07_26_020001_add_tier_to_subscriptions.php new file mode 100644 index 0000000..df80447 --- /dev/null +++ b/database/migrations/2026_07_26_020001_add_tier_to_subscriptions.php @@ -0,0 +1,29 @@ +unsignedTinyInteger('tier')->default(0)->after('plan'); + }); + } + + public function down(): void + { + Schema::table('subscriptions', function (Blueprint $table) { + $table->dropColumn('tier'); + }); + } +}; diff --git a/tests/Feature/Billing/PlanChangeTest.php b/tests/Feature/Billing/PlanChangeTest.php index a9b5596..35b8ae4 100644 --- a/tests/Feature/Billing/PlanChangeTest.php +++ b/tests/Feature/Billing/PlanChangeTest.php @@ -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); +});