From c034be820eb54c0ce7f05622f2e6dc2d0e3212c4494960cd2cb5624dce7bb313 Mon Sep 17 00:00:00 2001 From: Claude Dev Date: Sat, 14 Mar 2026 23:31:12 -0400 Subject: [PATCH] feat: add plan_prices table, PlanPrice model, and Plan relationship - Create plan_prices migration with unique(plan_id, billing_cycle) - PlanPrice model with factory - Plan::prices() relationship and priceForCycle() helper - Tests for model relationships, uniqueness, cascade delete Co-Authored-By: Claude Opus 4.6 (1M context) --- website/app/Models/Plan.php | 10 ++ website/app/Models/PlanPrice.php | 33 +++++ .../database/factories/PlanPriceFactory.php | 27 ++++ ..._03_15_032943_create_plan_prices_table.php | 27 ++++ website/tests/Feature/PlanPriceTest.php | 135 ++++++++++++++++++ 5 files changed, 232 insertions(+) create mode 100644 website/app/Models/PlanPrice.php create mode 100644 website/database/factories/PlanPriceFactory.php create mode 100644 website/database/migrations/2026_03_15_032943_create_plan_prices_table.php create mode 100644 website/tests/Feature/PlanPriceTest.php diff --git a/website/app/Models/Plan.php b/website/app/Models/Plan.php index 8f4943c..286ecdd 100644 --- a/website/app/Models/Plan.php +++ b/website/app/Models/Plan.php @@ -49,6 +49,16 @@ class Plan extends Model return $this->hasMany(Service::class); } + public function prices(): HasMany + { + return $this->hasMany(PlanPrice::class); + } + + public function priceForCycle(string $cycle): ?PlanPrice + { + return $this->prices()->where('billing_cycle', $cycle)->first(); + } + public function isAvailable(): bool { if ($this->status !== 'active') { diff --git a/website/app/Models/PlanPrice.php b/website/app/Models/PlanPrice.php new file mode 100644 index 0000000..4d6e0c2 --- /dev/null +++ b/website/app/Models/PlanPrice.php @@ -0,0 +1,33 @@ + 'decimal:2', + ]; + } + + public function plan(): BelongsTo + { + return $this->belongsTo(Plan::class); + } +} diff --git a/website/database/factories/PlanPriceFactory.php b/website/database/factories/PlanPriceFactory.php new file mode 100644 index 0000000..4d98d43 --- /dev/null +++ b/website/database/factories/PlanPriceFactory.php @@ -0,0 +1,27 @@ + + */ +class PlanPriceFactory extends Factory +{ + protected $model = PlanPrice::class; + + public function definition(): array + { + return [ + 'plan_id' => Plan::factory(), + 'billing_cycle' => fake()->randomElement(['monthly', 'quarterly', 'semi_annual', 'annual']), + 'price' => fake()->randomFloat(2, 3, 500), + 'stripe_price_id' => null, + ]; + } +} diff --git a/website/database/migrations/2026_03_15_032943_create_plan_prices_table.php b/website/database/migrations/2026_03_15_032943_create_plan_prices_table.php new file mode 100644 index 0000000..67bd449 --- /dev/null +++ b/website/database/migrations/2026_03_15_032943_create_plan_prices_table.php @@ -0,0 +1,27 @@ +id(); + $table->foreignId('plan_id')->constrained()->cascadeOnDelete(); + $table->string('billing_cycle'); + $table->decimal('price', 10, 2); + $table->string('stripe_price_id')->nullable(); + $table->timestamps(); + + $table->unique(['plan_id', 'billing_cycle']); + }); + } + + public function down(): void + { + Schema::dropIfExists('plan_prices'); + } +}; diff --git a/website/tests/Feature/PlanPriceTest.php b/website/tests/Feature/PlanPriceTest.php new file mode 100644 index 0000000..46ce3e8 --- /dev/null +++ b/website/tests/Feature/PlanPriceTest.php @@ -0,0 +1,135 @@ +create(); + + PlanPrice::create([ + 'plan_id' => $plan->id, + 'billing_cycle' => 'monthly', + 'price' => 5.00, + ]); + + PlanPrice::create([ + 'plan_id' => $plan->id, + 'billing_cycle' => 'annual', + 'price' => 51.00, + ]); + + expect($plan->prices)->toHaveCount(2); + expect($plan->prices->first())->toBeInstanceOf(PlanPrice::class); +}); + +it('resolves price for cycle', function () { + $plan = Plan::factory()->create(); + + PlanPrice::create([ + 'plan_id' => $plan->id, + 'billing_cycle' => 'monthly', + 'price' => 5.00, + ]); + + PlanPrice::create([ + 'plan_id' => $plan->id, + 'billing_cycle' => 'quarterly', + 'price' => 14.25, + ]); + + $monthly = $plan->priceForCycle('monthly'); + expect($monthly)->not->toBeNull(); + expect((float) $monthly->price)->toBe(5.00); + + $quarterly = $plan->priceForCycle('quarterly'); + expect((float) $quarterly->price)->toBe(14.25); + + $invalid = $plan->priceForCycle('weekly'); + expect($invalid)->toBeNull(); +}); + +it('enforces unique plan_id and billing_cycle', function () { + $plan = Plan::factory()->create(); + + PlanPrice::create([ + 'plan_id' => $plan->id, + 'billing_cycle' => 'monthly', + 'price' => 5.00, + ]); + + PlanPrice::create([ + 'plan_id' => $plan->id, + 'billing_cycle' => 'monthly', + 'price' => 6.00, + ]); +})->throws(\Illuminate\Database\QueryException::class); + +it('cascade deletes plan prices when plan is deleted', function () { + $plan = Plan::factory()->create(); + + PlanPrice::create([ + 'plan_id' => $plan->id, + 'billing_cycle' => 'monthly', + 'price' => 5.00, + ]); + + PlanPrice::create([ + 'plan_id' => $plan->id, + 'billing_cycle' => 'annual', + 'price' => 51.00, + ]); + + expect(PlanPrice::where('plan_id', $plan->id)->count())->toBe(2); + + $plan->delete(); + + expect(PlanPrice::where('plan_id', $plan->id)->count())->toBe(0); +}); + +it('seeds all VPS plans with 4 price cycles each', function () { + $this->seed(\Database\Seeders\PlanSeeder::class); + + $vpsSlugs = ['vps-1', 'vps-2', 'vps-4', 'vps-8', 'vps-16', 'vps-32', 'stor-500', 'stor-1tb']; + + foreach ($vpsSlugs as $slug) { + $plan = Plan::where('slug', $slug)->first(); + expect($plan)->not->toBeNull("Plan {$slug} should exist"); + expect($plan->status)->toBe('active'); + expect($plan->prices)->toHaveCount(4, "Plan {$slug} should have 4 price cycles"); + + $cycles = $plan->prices->pluck('billing_cycle')->sort()->values()->toArray(); + expect($cycles)->toBe(['annual', 'monthly', 'quarterly', 'semi_annual']); + } +}); + +it('archives old VPS plan slugs', function () { + Plan::factory()->create(['slug' => 'vps-nano', 'service_type' => 'vps', 'status' => 'active']); + + $this->seed(\Database\Seeders\PlanSeeder::class); + + $oldPlan = Plan::where('slug', 'vps-nano')->first(); + expect($oldPlan->status)->toBe('archived'); +}); + +it('sets correct monthly base prices', function () { + $this->seed(\Database\Seeders\PlanSeeder::class); + + $expectedPrices = [ + 'vps-1' => 5.00, + 'vps-2' => 8.00, + 'vps-4' => 15.00, + 'vps-8' => 30.00, + 'vps-16' => 55.00, + 'vps-32' => 99.00, + 'stor-500' => 18.00, + 'stor-1tb' => 28.00, + ]; + + foreach ($expectedPrices as $slug => $expectedPrice) { + $plan = Plan::where('slug', $slug)->first(); + expect((float) $plan->price)->toBe($expectedPrice, "Plan {$slug} monthly price mismatch"); + expect((float) $plan->priceForCycle('monthly')->price)->toBe($expectedPrice); + } +});