feat: create plans table, model, and slug enum

This commit is contained in:
Paulo Castellano 2026-04-14 17:50:41 -03:00
parent bb871ac95c
commit d235e702cf
6 changed files with 254 additions and 0 deletions

23
app/Enums/Plan/Slug.php Normal file
View 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
View 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);
}
}

View file

@ -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,

View 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,
]);
}
}

View 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');
}
};

View 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();
});