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) <noreply@anthropic.com>
This commit is contained in:
@@ -49,6 +49,16 @@ class Plan extends Model
|
|||||||
return $this->hasMany(Service::class);
|
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
|
public function isAvailable(): bool
|
||||||
{
|
{
|
||||||
if ($this->status !== 'active') {
|
if ($this->status !== 'active') {
|
||||||
|
|||||||
33
website/app/Models/PlanPrice.php
Normal file
33
website/app/Models/PlanPrice.php
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
class PlanPrice extends Model
|
||||||
|
{
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'plan_id',
|
||||||
|
'billing_cycle',
|
||||||
|
'price',
|
||||||
|
'stripe_price_id',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'price' => 'decimal:2',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function plan(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Plan::class);
|
||||||
|
}
|
||||||
|
}
|
||||||
27
website/database/factories/PlanPriceFactory.php
Normal file
27
website/database/factories/PlanPriceFactory.php
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Database\Factories;
|
||||||
|
|
||||||
|
use App\Models\Plan;
|
||||||
|
use App\Models\PlanPrice;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @extends Factory<PlanPrice>
|
||||||
|
*/
|
||||||
|
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,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('plan_prices', function (Blueprint $table) {
|
||||||
|
$table->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');
|
||||||
|
}
|
||||||
|
};
|
||||||
135
website/tests/Feature/PlanPriceTest.php
Normal file
135
website/tests/Feature/PlanPriceTest.php
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Models\Plan;
|
||||||
|
use App\Models\PlanPrice;
|
||||||
|
|
||||||
|
it('has prices relationship', 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($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);
|
||||||
|
}
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user