From dd89ee395ccf84e9cd9f38d85f9894fd82e44557 Mon Sep 17 00:00:00 2001 From: boban Date: Sat, 16 May 2026 01:06:01 +0200 Subject: [PATCH] feat(billing): plan limits service, plan factory, seeder (free/pro/business/agency) --- app/Domains/Subscription/Models/Plan.php | 8 ++++ .../Services/PlanLimitsService.php | 40 +++++++++++++++++++ database/factories/PlanFactory.php | 24 +++++++++++ database/seeders/PlanSeeder.php | 23 +++++++++++ .../Subscription/PlanLimitsServiceTest.php | 33 +++++++++++++++ 5 files changed, 128 insertions(+) create mode 100644 app/Domains/Subscription/Services/PlanLimitsService.php create mode 100644 database/factories/PlanFactory.php create mode 100644 database/seeders/PlanSeeder.php create mode 100644 tests/Unit/Subscription/PlanLimitsServiceTest.php diff --git a/app/Domains/Subscription/Models/Plan.php b/app/Domains/Subscription/Models/Plan.php index 57feb37..8135f93 100644 --- a/app/Domains/Subscription/Models/Plan.php +++ b/app/Domains/Subscription/Models/Plan.php @@ -2,17 +2,25 @@ namespace App\Domains\Subscription\Models; +use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\HasMany; class Plan extends Model { + use HasFactory; + protected $guarded = ['id']; protected $casts = [ 'features' => 'array', ]; + protected static function newFactory(): \Database\Factories\PlanFactory + { + return \Database\Factories\PlanFactory::new(); + } + public function workspaces(): HasMany { return $this->hasMany(\App\Domains\Workspace\Models\Workspace::class); diff --git a/app/Domains/Subscription/Services/PlanLimitsService.php b/app/Domains/Subscription/Services/PlanLimitsService.php new file mode 100644 index 0000000..4000e20 --- /dev/null +++ b/app/Domains/Subscription/Services/PlanLimitsService.php @@ -0,0 +1,40 @@ +plan; + if (! $plan) { + return true; // No plan = allow (trial or free) + } + $count = $workspace->links()->count(); + return $count < $plan->link_limit; + } + + public function monthlyClicksUsed(Workspace $workspace): int + { + $key = "clicks:{$workspace->id}:monthly:" . now()->format('Ym'); + return (int) Redis::get($key); + } + + public function isClickLimitApproaching(Workspace $workspace): bool + { + $plan = $workspace->plan; + if (! $plan) return false; + $used = $this->monthlyClicksUsed($workspace); + return $used >= ($plan->click_limit_monthly * 0.9); + } + + public function isClickLimitExceeded(Workspace $workspace): bool + { + $plan = $workspace->plan; + if (! $plan) return false; + return $this->monthlyClicksUsed($workspace) >= $plan->click_limit_monthly; + } +} diff --git a/database/factories/PlanFactory.php b/database/factories/PlanFactory.php new file mode 100644 index 0000000..e513b25 --- /dev/null +++ b/database/factories/PlanFactory.php @@ -0,0 +1,24 @@ + fake()->unique()->word(), + 'monthly_price_cents' => 0, + 'workspace_limit' => 1, + 'member_limit' => 1, + 'custom_domain_limit' => 0, + 'link_limit' => 50, + 'click_limit_monthly' => 1000, + ]; + } +} diff --git a/database/seeders/PlanSeeder.php b/database/seeders/PlanSeeder.php new file mode 100644 index 0000000..1a8f46f --- /dev/null +++ b/database/seeders/PlanSeeder.php @@ -0,0 +1,23 @@ + 'free', 'monthly_price_cents' => 0, 'workspace_limit' => 1, 'member_limit' => 1, 'custom_domain_limit' => 0, 'link_limit' => 50, 'click_limit_monthly' => 1000], + ['name' => 'pro', 'monthly_price_cents' => 1900, 'workspace_limit' => 1, 'member_limit' => 3, 'custom_domain_limit' => 1, 'link_limit' => 1000, 'click_limit_monthly' => 25000], + ['name' => 'business', 'monthly_price_cents' => 7900, 'workspace_limit' => 3, 'member_limit' => 10, 'custom_domain_limit' => 5, 'link_limit' => 10000, 'click_limit_monthly' => 250000], + ['name' => 'agency', 'monthly_price_cents' => 19900, 'workspace_limit' => 999, 'member_limit' => 999, 'custom_domain_limit' => 999, 'link_limit' => 999999, 'click_limit_monthly' => 5000000], + ]; + + foreach ($plans as $plan) { + Plan::updateOrCreate(['name' => $plan['name']], $plan); + } + } +} diff --git a/tests/Unit/Subscription/PlanLimitsServiceTest.php b/tests/Unit/Subscription/PlanLimitsServiceTest.php new file mode 100644 index 0000000..6767b03 --- /dev/null +++ b/tests/Unit/Subscription/PlanLimitsServiceTest.php @@ -0,0 +1,33 @@ +create(['link_limit' => 10]); + $workspace = Workspace::factory()->create(['plan_id' => $plan->id]); + + $service = new PlanLimitsService; + expect($service->canCreateLink($workspace))->toBeTrue(); +}); + +it('denies link creation when at limit', function () { + $plan = Plan::factory()->create(['link_limit' => 2]); + $workspace = Workspace::factory()->create(['plan_id' => $plan->id]); + Link::factory()->count(2)->create(['workspace_id' => $workspace->id]); + + $service = new PlanLimitsService; + expect($service->canCreateLink($workspace))->toBeFalse(); +}); + +it('returns true when workspace has no plan', function () { + $workspace = Workspace::factory()->create(['plan_id' => null]); + + $service = new PlanLimitsService; + // No plan = free tier or trial = allow + expect($service->canCreateLink($workspace))->toBeTrue(); +});