feat: create plans table, model, and slug enum
This commit is contained in:
parent
bb871ac95c
commit
d235e702cf
6 changed files with 254 additions and 0 deletions
23
app/Enums/Plan/Slug.php
Normal file
23
app/Enums/Plan/Slug.php
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Enums\Plan;
|
||||
|
||||
enum Slug: string
|
||||
{
|
||||
case Starter = 'starter';
|
||||
case Plus = 'plus';
|
||||
case Pro = 'pro';
|
||||
case Max = 'max';
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::Starter => 'Starter',
|
||||
self::Plus => 'Plus',
|
||||
self::Pro => 'Pro',
|
||||
self::Max => 'Max',
|
||||
};
|
||||
}
|
||||
}
|
||||
73
app/Models/Plan.php
Normal file
73
app/Models/Plan.php
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Enums\Plan\Slug;
|
||||
use Database\Factories\PlanFactory;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class Plan extends Model
|
||||
{
|
||||
/** @use HasFactory<PlanFactory> */
|
||||
use HasFactory, HasUuids;
|
||||
|
||||
protected $fillable = [
|
||||
'slug',
|
||||
'name',
|
||||
'stripe_monthly_price_id',
|
||||
'stripe_yearly_price_id',
|
||||
'monthly_price',
|
||||
'yearly_price',
|
||||
'social_account_limit',
|
||||
'member_limit',
|
||||
'brand_limit',
|
||||
'ai_images_limit',
|
||||
'ai_videos_limit',
|
||||
'data_retention_days',
|
||||
'sort',
|
||||
'is_archived',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'slug' => Slug::class,
|
||||
'is_archived' => 'boolean',
|
||||
'monthly_price' => 'integer',
|
||||
'yearly_price' => 'integer',
|
||||
'social_account_limit' => 'integer',
|
||||
'member_limit' => 'integer',
|
||||
'brand_limit' => 'integer',
|
||||
'ai_images_limit' => 'integer',
|
||||
'ai_videos_limit' => 'integer',
|
||||
'data_retention_days' => 'integer',
|
||||
'sort' => 'integer',
|
||||
];
|
||||
}
|
||||
|
||||
public function workspaces(): HasMany
|
||||
{
|
||||
return $this->hasMany(Workspace::class);
|
||||
}
|
||||
|
||||
public function scopeActive(Builder $query): Builder
|
||||
{
|
||||
return $query->where('is_archived', false);
|
||||
}
|
||||
|
||||
public function formattedMonthlyPrice(): string
|
||||
{
|
||||
return '$'.number_format($this->monthly_price / 100, 0);
|
||||
}
|
||||
|
||||
public function formattedYearlyPrice(): string
|
||||
{
|
||||
return '$'.number_format($this->yearly_price / 100, 0);
|
||||
}
|
||||
}
|
||||
|
|
@ -8,6 +8,7 @@
|
|||
use App\Models\Media;
|
||||
use App\Models\Notification;
|
||||
use App\Models\NotificationPreference;
|
||||
use App\Models\Plan;
|
||||
use App\Models\Post;
|
||||
use App\Models\PostPlatform;
|
||||
use App\Models\SocialAccount;
|
||||
|
|
@ -82,6 +83,7 @@ protected function configureMorphMap(): void
|
|||
Relation::enforceMorphMap([
|
||||
'media' => Media::class,
|
||||
'notification' => Notification::class,
|
||||
'plan' => Plan::class,
|
||||
'notificationPreference' => NotificationPreference::class,
|
||||
'post' => Post::class,
|
||||
'postPlatform' => PostPlatform::class,
|
||||
|
|
|
|||
47
database/factories/PlanFactory.php
Normal file
47
database/factories/PlanFactory.php
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Enums\Plan\Slug;
|
||||
use App\Models\Plan;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/**
|
||||
* @extends Factory<Plan>
|
||||
*/
|
||||
class PlanFactory extends Factory
|
||||
{
|
||||
/**
|
||||
* Define the model's default state.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'slug' => fake()->randomElement(Slug::cases()),
|
||||
'name' => fake()->word(),
|
||||
'stripe_monthly_price_id' => null,
|
||||
'stripe_yearly_price_id' => null,
|
||||
'monthly_price' => fake()->randomElement([900, 1900, 3900, 7900]),
|
||||
'yearly_price' => fake()->randomElement([9000, 19000, 39000, 79000]),
|
||||
'social_account_limit' => fake()->randomElement([3, 10, 25, 100]),
|
||||
'member_limit' => fake()->randomElement([1, 3, 10, 50]),
|
||||
'brand_limit' => fake()->randomElement([1, 3, 10, 50]),
|
||||
'ai_images_limit' => fake()->randomElement([10, 50, 200, 1000]),
|
||||
'ai_videos_limit' => fake()->randomElement([5, 25, 100, 500]),
|
||||
'data_retention_days' => fake()->randomElement([30, 90, 365, 730]),
|
||||
'sort' => 0,
|
||||
'is_archived' => false,
|
||||
];
|
||||
}
|
||||
|
||||
public function archived(): static
|
||||
{
|
||||
return $this->state(fn (array $attributes): array => [
|
||||
'is_archived' => true,
|
||||
]);
|
||||
}
|
||||
}
|
||||
37
database/migrations/2026_04_14_204912_create_plans_table.php
Normal file
37
database/migrations/2026_04_14_204912_create_plans_table.php
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
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('plans', function (Blueprint $table) {
|
||||
$table->uuid('id')->primary();
|
||||
$table->string('slug')->unique();
|
||||
$table->string('name');
|
||||
$table->string('stripe_monthly_price_id')->nullable();
|
||||
$table->string('stripe_yearly_price_id')->nullable();
|
||||
$table->integer('monthly_price');
|
||||
$table->integer('yearly_price');
|
||||
$table->integer('social_account_limit');
|
||||
$table->integer('member_limit');
|
||||
$table->integer('brand_limit');
|
||||
$table->integer('ai_images_limit');
|
||||
$table->integer('ai_videos_limit');
|
||||
$table->integer('data_retention_days');
|
||||
$table->integer('sort')->default(0);
|
||||
$table->boolean('is_archived')->default(false);
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('plans');
|
||||
}
|
||||
};
|
||||
72
tests/Feature/PlanTest.php
Normal file
72
tests/Feature/PlanTest.php
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Enums\Plan\Slug;
|
||||
use App\Models\Plan;
|
||||
|
||||
test('plan can be created with factory', function () {
|
||||
$plan = Plan::factory()->create([
|
||||
'slug' => Slug::Pro,
|
||||
'name' => 'Pro',
|
||||
'monthly_price' => 3900,
|
||||
'yearly_price' => 39000,
|
||||
]);
|
||||
|
||||
expect($plan)->toBeInstanceOf(Plan::class)
|
||||
->and($plan->slug)->toBe(Slug::Pro)
|
||||
->and($plan->name)->toBe('Pro')
|
||||
->and($plan->monthly_price)->toBe(3900)
|
||||
->and($plan->yearly_price)->toBe(39000)
|
||||
->and($plan->is_archived)->toBeFalse();
|
||||
});
|
||||
|
||||
test('plan slug is cast to enum', function () {
|
||||
$plan = Plan::factory()->create(['slug' => Slug::Starter]);
|
||||
|
||||
expect($plan->slug)->toBeInstanceOf(Slug::class)
|
||||
->and($plan->slug)->toBe(Slug::Starter)
|
||||
->and($plan->slug->label())->toBe('Starter');
|
||||
});
|
||||
|
||||
test('active scope excludes archived plans', function () {
|
||||
Plan::factory()->create(['slug' => Slug::Starter, 'is_archived' => false]);
|
||||
Plan::factory()->create(['slug' => Slug::Plus, 'is_archived' => false]);
|
||||
Plan::factory()->archived()->create(['slug' => Slug::Pro]);
|
||||
|
||||
$activePlans = Plan::active()->get();
|
||||
|
||||
expect($activePlans)->toHaveCount(2);
|
||||
});
|
||||
|
||||
test('formatted monthly price returns dollar format', function () {
|
||||
$plan = Plan::factory()->create(['monthly_price' => 1900]);
|
||||
|
||||
expect($plan->formattedMonthlyPrice())->toBe('$19');
|
||||
});
|
||||
|
||||
test('formatted yearly price returns dollar format', function () {
|
||||
$plan = Plan::factory()->create(['yearly_price' => 19000]);
|
||||
|
||||
expect($plan->formattedYearlyPrice())->toBe('$190');
|
||||
});
|
||||
|
||||
test('integer fields are cast correctly', function () {
|
||||
$plan = Plan::factory()->create([
|
||||
'social_account_limit' => 10,
|
||||
'member_limit' => 3,
|
||||
'brand_limit' => 5,
|
||||
'ai_images_limit' => 50,
|
||||
'ai_videos_limit' => 25,
|
||||
'data_retention_days' => 365,
|
||||
'sort' => 2,
|
||||
]);
|
||||
|
||||
expect($plan->social_account_limit)->toBeInt()
|
||||
->and($plan->member_limit)->toBeInt()
|
||||
->and($plan->brand_limit)->toBeInt()
|
||||
->and($plan->ai_images_limit)->toBeInt()
|
||||
->and($plan->ai_videos_limit)->toBeInt()
|
||||
->and($plan->data_retention_days)->toBeInt()
|
||||
->and($plan->sort)->toBeInt();
|
||||
});
|
||||
Loading…
Reference in a new issue