From d235e702cff203a65b13d8bb04eef92f408e5100 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Tue, 14 Apr 2026 17:50:41 -0300 Subject: [PATCH] feat: create plans table, model, and slug enum --- app/Enums/Plan/Slug.php | 23 ++++++ app/Models/Plan.php | 73 +++++++++++++++++++ app/Providers/AppServiceProvider.php | 2 + database/factories/PlanFactory.php | 47 ++++++++++++ .../2026_04_14_204912_create_plans_table.php | 37 ++++++++++ tests/Feature/PlanTest.php | 72 ++++++++++++++++++ 6 files changed, 254 insertions(+) create mode 100644 app/Enums/Plan/Slug.php create mode 100644 app/Models/Plan.php create mode 100644 database/factories/PlanFactory.php create mode 100644 database/migrations/2026_04_14_204912_create_plans_table.php create mode 100644 tests/Feature/PlanTest.php diff --git a/app/Enums/Plan/Slug.php b/app/Enums/Plan/Slug.php new file mode 100644 index 00000000..6a9ed87e --- /dev/null +++ b/app/Enums/Plan/Slug.php @@ -0,0 +1,23 @@ + 'Starter', + self::Plus => 'Plus', + self::Pro => 'Pro', + self::Max => 'Max', + }; + } +} diff --git a/app/Models/Plan.php b/app/Models/Plan.php new file mode 100644 index 00000000..7a7a1961 --- /dev/null +++ b/app/Models/Plan.php @@ -0,0 +1,73 @@ + */ + 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); + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 92a9faca..7242c434 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -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, diff --git a/database/factories/PlanFactory.php b/database/factories/PlanFactory.php new file mode 100644 index 00000000..a3bdc38b --- /dev/null +++ b/database/factories/PlanFactory.php @@ -0,0 +1,47 @@ + + */ +class PlanFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + 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, + ]); + } +} diff --git a/database/migrations/2026_04_14_204912_create_plans_table.php b/database/migrations/2026_04_14_204912_create_plans_table.php new file mode 100644 index 00000000..f34ffb22 --- /dev/null +++ b/database/migrations/2026_04_14_204912_create_plans_table.php @@ -0,0 +1,37 @@ +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'); + } +}; diff --git a/tests/Feature/PlanTest.php b/tests/Feature/PlanTest.php new file mode 100644 index 00000000..b6a1ddf5 --- /dev/null +++ b/tests/Feature/PlanTest.php @@ -0,0 +1,72 @@ +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(); +});