user = User::factory()->create(); $this->workspace = Workspace::factory()->create(); $this->workspace->users()->attach($this->user->id, [ 'role' => 'owner', 'is_default' => true, ]); // Create features $this->aiCreditsFeature = Feature::create([ 'code' => 'ai.credits', 'name' => 'AI Credits', 'description' => 'AI generation credits', 'category' => 'ai', 'type' => Feature::TYPE_LIMIT, 'reset_type' => Feature::RESET_MONTHLY, 'is_active' => true, 'sort_order' => 1, ]); $this->apolloTierFeature = Feature::create([ 'code' => 'tier.apollo', 'name' => 'Apollo Tier', 'description' => 'Apollo tier access', 'category' => 'tier', 'type' => Feature::TYPE_BOOLEAN, 'reset_type' => Feature::RESET_NONE, 'is_active' => true, 'sort_order' => 1, ]); $this->socialPostsFeature = Feature::create([ 'code' => 'social.posts', 'name' => 'Scheduled Posts', 'description' => 'Monthly scheduled posts', 'category' => 'social', 'type' => Feature::TYPE_LIMIT, 'reset_type' => Feature::RESET_MONTHLY, 'is_active' => true, 'sort_order' => 1, ]); // Create packages $this->creatorPackage = Package::create([ 'code' => 'creator', 'name' => 'Creator', 'description' => 'For individual creators', 'is_stackable' => false, 'is_base_package' => true, 'is_active' => true, 'is_public' => true, 'sort_order' => 1, ]); $this->agencyPackage = Package::create([ 'code' => 'agency', 'name' => 'Agency', 'description' => 'For agencies', 'is_stackable' => false, 'is_base_package' => true, 'is_active' => true, 'is_public' => true, 'sort_order' => 2, ]); // Attach features to packages $this->creatorPackage->features()->attach($this->aiCreditsFeature->id, ['limit_value' => 100]); $this->creatorPackage->features()->attach($this->apolloTierFeature->id, ['limit_value' => null]); $this->creatorPackage->features()->attach($this->socialPostsFeature->id, ['limit_value' => 50]); $this->agencyPackage->features()->attach($this->aiCreditsFeature->id, ['limit_value' => 500]); $this->agencyPackage->features()->attach($this->apolloTierFeature->id, ['limit_value' => null]); $this->agencyPackage->features()->attach($this->socialPostsFeature->id, ['limit_value' => 200]); $this->service = app(EntitlementService::class); }); describe('EntitlementService', function () { describe('can() method', function () { it('denies access when workspace has no packages', function () { $result = $this->service->can($this->workspace, 'ai.credits'); expect($result)->toBeInstanceOf(EntitlementResult::class) ->and($result->isAllowed())->toBeFalse() ->and($result->isDenied())->toBeTrue() ->and($result->reason)->toContain('plan does not include'); }); it('allows access when workspace has package with feature', function () { $this->service->provisionPackage($this->workspace, 'creator'); $result = $this->service->can($this->workspace, 'ai.credits'); expect($result->isAllowed())->toBeTrue() ->and($result->limit)->toBe(100) ->and($result->used)->toBe(0); }); it('allows boolean features without limits', function () { $this->service->provisionPackage($this->workspace, 'creator'); $result = $this->service->can($this->workspace, 'tier.apollo'); expect($result->isAllowed())->toBeTrue() ->and($result->limit)->toBeNull(); }); it('denies access when limit is exceeded', function () { $this->service->provisionPackage($this->workspace, 'creator'); // Record usage up to the limit for ($i = 0; $i < 100; $i++) { UsageRecord::create([ 'workspace_id' => $this->workspace->id, 'feature_code' => 'ai.credits', 'quantity' => 1, 'recorded_at' => now(), ]); } Cache::flush(); $result = $this->service->can($this->workspace, 'ai.credits'); expect($result->isDenied())->toBeTrue() ->and($result->used)->toBe(100) ->and($result->limit)->toBe(100) ->and($result->reason)->toContain('reached your'); }); it('allows access when quantity is within remaining limit', function () { $this->service->provisionPackage($this->workspace, 'creator'); // Use 50 credits UsageRecord::create([ 'workspace_id' => $this->workspace->id, 'feature_code' => 'ai.credits', 'quantity' => 50, 'recorded_at' => now(), ]); Cache::flush(); $result = $this->service->can($this->workspace, 'ai.credits', quantity: 25); expect($result->isAllowed())->toBeTrue() ->and($result->remaining)->toBe(50); }); it('denies access when requested quantity exceeds remaining', function () { $this->service->provisionPackage($this->workspace, 'creator'); // Use 90 credits UsageRecord::create([ 'workspace_id' => $this->workspace->id, 'feature_code' => 'ai.credits', 'quantity' => 90, 'recorded_at' => now(), ]); Cache::flush(); $result = $this->service->can($this->workspace, 'ai.credits', quantity: 20); expect($result->isDenied())->toBeTrue() ->and($result->used)->toBe(90) ->and($result->remaining)->toBe(10); }); it('denies access for non-existent feature', function () { $result = $this->service->can($this->workspace, 'non.existent.feature'); expect($result->isDenied())->toBeTrue() ->and($result->reason)->toContain('does not exist'); }); }); describe('recordUsage() method', function () { it('creates a usage record', function () { $this->service->provisionPackage($this->workspace, 'creator'); $record = $this->service->recordUsage( $this->workspace, 'ai.credits', quantity: 5, user: $this->user ); expect($record)->toBeInstanceOf(UsageRecord::class) ->and($record->workspace_id)->toBe($this->workspace->id) ->and($record->feature_code)->toBe('ai.credits') ->and($record->quantity)->toBe(5) ->and($record->user_id)->toBe($this->user->id); }); it('records usage with metadata', function () { $this->service->provisionPackage($this->workspace, 'creator'); $record = $this->service->recordUsage( $this->workspace, 'ai.credits', quantity: 1, metadata: ['model' => 'claude-3', 'tokens' => 1500] ); expect($record->metadata)->toBe(['model' => 'claude-3', 'tokens' => 1500]); }); it('invalidates cache after recording usage', function () { $this->service->provisionPackage($this->workspace, 'creator'); // Warm up cache $this->service->can($this->workspace, 'ai.credits'); // Record usage $this->service->recordUsage($this->workspace, 'ai.credits', quantity: 10); // Check that usage is reflected (cache was invalidated) $result = $this->service->can($this->workspace, 'ai.credits'); expect($result->used)->toBe(10); }); }); describe('provisionPackage() method', function () { it('provisions a package to workspace', function () { $workspacePackage = $this->service->provisionPackage($this->workspace, 'creator'); expect($workspacePackage)->toBeInstanceOf(WorkspacePackage::class) ->and($workspacePackage->workspace_id)->toBe($this->workspace->id) ->and($workspacePackage->package->code)->toBe('creator') ->and($workspacePackage->status)->toBe(WorkspacePackage::STATUS_ACTIVE); }); it('creates an entitlement log entry', function () { $this->service->provisionPackage($this->workspace, 'creator', [ 'source' => EntitlementLog::SOURCE_BLESTA, ]); $log = EntitlementLog::where('workspace_id', $this->workspace->id) ->where('action', EntitlementLog::ACTION_PACKAGE_PROVISIONED) ->first(); expect($log)->not->toBeNull() ->and($log->source)->toBe(EntitlementLog::SOURCE_BLESTA); }); it('replaces existing base package when provisioning new base package', function () { // Provision creator package $creatorWp = $this->service->provisionPackage($this->workspace, 'creator'); // Provision agency package (should cancel creator) $agencyWp = $this->service->provisionPackage($this->workspace, 'agency'); // Refresh creator package $creatorWp->refresh(); expect($creatorWp->status)->toBe(WorkspacePackage::STATUS_CANCELLED) ->and($agencyWp->status)->toBe(WorkspacePackage::STATUS_ACTIVE); }); it('sets billing cycle anchor', function () { $anchor = now()->subDays(15); $workspacePackage = $this->service->provisionPackage($this->workspace, 'creator', [ 'billing_cycle_anchor' => $anchor, ]); expect($workspacePackage->billing_cycle_anchor->toDateString()) ->toBe($anchor->toDateString()); }); it('stores blesta service id', function () { $workspacePackage = $this->service->provisionPackage($this->workspace, 'creator', [ 'blesta_service_id' => 'blesta_12345', ]); expect($workspacePackage->blesta_service_id)->toBe('blesta_12345'); }); }); describe('provisionBoost() method', function () { it('provisions a boost to workspace', function () { $this->service->provisionPackage($this->workspace, 'creator'); $boost = $this->service->provisionBoost($this->workspace, 'ai.credits', [ 'boost_type' => Boost::BOOST_TYPE_ADD_LIMIT, 'limit_value' => 100, 'duration_type' => Boost::DURATION_CYCLE_BOUND, ]); expect($boost)->toBeInstanceOf(Boost::class) ->and($boost->workspace_id)->toBe($this->workspace->id) ->and($boost->feature_code)->toBe('ai.credits') ->and($boost->limit_value)->toBe(100) ->and($boost->status)->toBe(Boost::STATUS_ACTIVE); }); it('adds boost limit to package limit', function () { $this->service->provisionPackage($this->workspace, 'creator'); // 100 credits $this->service->provisionBoost($this->workspace, 'ai.credits', [ 'boost_type' => Boost::BOOST_TYPE_ADD_LIMIT, 'limit_value' => 50, ]); Cache::flush(); $result = $this->service->can($this->workspace, 'ai.credits'); expect($result->limit)->toBe(150); // 100 + 50 }); it('creates an entitlement log entry for boost', function () { $this->service->provisionBoost($this->workspace, 'ai.credits', [ 'limit_value' => 100, ]); $log = EntitlementLog::where('workspace_id', $this->workspace->id) ->where('action', EntitlementLog::ACTION_BOOST_PROVISIONED) ->first(); expect($log)->not->toBeNull(); }); }); describe('suspendWorkspace() method', function () { it('suspends all active packages', function () { $workspacePackage = $this->service->provisionPackage($this->workspace, 'creator'); $this->service->suspendWorkspace($this->workspace); $workspacePackage->refresh(); expect($workspacePackage->status)->toBe(WorkspacePackage::STATUS_SUSPENDED); }); it('creates suspension log entries', function () { $this->service->provisionPackage($this->workspace, 'creator'); $this->service->suspendWorkspace($this->workspace, EntitlementLog::SOURCE_BLESTA); $log = EntitlementLog::where('workspace_id', $this->workspace->id) ->where('action', EntitlementLog::ACTION_PACKAGE_SUSPENDED) ->first(); expect($log)->not->toBeNull() ->and($log->source)->toBe(EntitlementLog::SOURCE_BLESTA); }); it('denies access after suspension', function () { $this->service->provisionPackage($this->workspace, 'creator'); // Can access before suspension expect($this->service->can($this->workspace, 'ai.credits')->isAllowed())->toBeTrue(); $this->service->suspendWorkspace($this->workspace); Cache::flush(); // Cannot access after suspension expect($this->service->can($this->workspace, 'ai.credits')->isDenied())->toBeTrue(); }); }); describe('reactivateWorkspace() method', function () { it('reactivates suspended packages', function () { $workspacePackage = $this->service->provisionPackage($this->workspace, 'creator'); $this->service->suspendWorkspace($this->workspace); $this->service->reactivateWorkspace($this->workspace); $workspacePackage->refresh(); expect($workspacePackage->status)->toBe(WorkspacePackage::STATUS_ACTIVE); }); it('creates reactivation log entries', function () { $this->service->provisionPackage($this->workspace, 'creator'); $this->service->suspendWorkspace($this->workspace); $this->service->reactivateWorkspace($this->workspace, EntitlementLog::SOURCE_BLESTA); $log = EntitlementLog::where('workspace_id', $this->workspace->id) ->where('action', EntitlementLog::ACTION_PACKAGE_REACTIVATED) ->first(); expect($log)->not->toBeNull() ->and($log->source)->toBe(EntitlementLog::SOURCE_BLESTA); }); it('restores access after reactivation', function () { $this->service->provisionPackage($this->workspace, 'creator'); $this->service->suspendWorkspace($this->workspace); $this->service->reactivateWorkspace($this->workspace); Cache::flush(); expect($this->service->can($this->workspace, 'ai.credits')->isAllowed())->toBeTrue(); }); }); describe('getUsageSummary() method', function () { it('returns usage summary for all features', function () { $this->service->provisionPackage($this->workspace, 'creator'); $summary = $this->service->getUsageSummary($this->workspace); expect($summary)->toBeInstanceOf(\Illuminate\Support\Collection::class) ->and($summary->has('ai'))->toBeTrue() ->and($summary->has('tier'))->toBeTrue() ->and($summary->has('social'))->toBeTrue(); }); it('includes usage percentages', function () { $this->service->provisionPackage($this->workspace, 'creator'); // Use 50 of 100 credits $this->service->recordUsage($this->workspace, 'ai.credits', quantity: 50); $summary = $this->service->getUsageSummary($this->workspace); $aiFeature = $summary->get('ai')->first(); expect($aiFeature['used'])->toBe(50) ->and($aiFeature['limit'])->toBe(100) ->and((int) $aiFeature['percentage'])->toBe(50); }); }); describe('getActivePackages() method', function () { it('returns only active packages', function () { $this->service->provisionPackage($this->workspace, 'creator'); $this->service->suspendWorkspace($this->workspace); $activePackages = $this->service->getActivePackages($this->workspace); expect($activePackages)->toHaveCount(0); }); it('excludes expired packages', function () { $wp = $this->service->provisionPackage($this->workspace, 'creator', [ 'expires_at' => now()->subDay(), ]); $activePackages = $this->service->getActivePackages($this->workspace); expect($activePackages)->toHaveCount(0); }); }); describe('getActiveBoosts() method', function () { it('returns only active boosts', function () { $boost = $this->service->provisionBoost($this->workspace, 'ai.credits', [ 'limit_value' => 100, ]); $activeBoosts = $this->service->getActiveBoosts($this->workspace); expect($activeBoosts)->toHaveCount(1); // Cancel the boost $boost->update(['status' => Boost::STATUS_CANCELLED]); $activeBoosts = $this->service->getActiveBoosts($this->workspace); expect($activeBoosts)->toHaveCount(0); }); }); describe('revokePackage() method', function () { it('revokes an active package', function () { $workspacePackage = $this->service->provisionPackage($this->workspace, 'creator'); expect($workspacePackage->status)->toBe(WorkspacePackage::STATUS_ACTIVE); $this->service->revokePackage($this->workspace, 'creator'); $workspacePackage->refresh(); expect($workspacePackage->status)->toBe(WorkspacePackage::STATUS_CANCELLED) ->and($workspacePackage->expires_at)->not->toBeNull(); }); it('creates a cancellation log entry', function () { $this->service->provisionPackage($this->workspace, 'creator'); $this->service->revokePackage($this->workspace, 'creator', EntitlementLog::SOURCE_SYSTEM); $log = EntitlementLog::where('workspace_id', $this->workspace->id) ->where('action', EntitlementLog::ACTION_PACKAGE_CANCELLED) ->first(); expect($log)->not->toBeNull() ->and($log->source)->toBe(EntitlementLog::SOURCE_SYSTEM); }); it('denies access after package revocation', function () { $this->service->provisionPackage($this->workspace, 'creator'); // Can access before revocation expect($this->service->can($this->workspace, 'ai.credits')->isAllowed())->toBeTrue(); $this->service->revokePackage($this->workspace, 'creator'); Cache::flush(); // Cannot access after revocation expect($this->service->can($this->workspace, 'ai.credits')->isDenied())->toBeTrue(); }); it('does nothing when package does not exist', function () { // Should not throw, just return silently $this->service->revokePackage($this->workspace, 'nonexistent-package'); // No log entries should be created $log = EntitlementLog::where('workspace_id', $this->workspace->id) ->where('action', EntitlementLog::ACTION_PACKAGE_CANCELLED) ->first(); expect($log)->toBeNull(); }); it('does nothing when package already cancelled', function () { $workspacePackage = $this->service->provisionPackage($this->workspace, 'creator'); $workspacePackage->update(['status' => WorkspacePackage::STATUS_CANCELLED]); // Should not throw $this->service->revokePackage($this->workspace, 'creator'); // Only one log entry (from provisioning, not cancellation) $logs = EntitlementLog::where('workspace_id', $this->workspace->id) ->where('action', EntitlementLog::ACTION_PACKAGE_CANCELLED) ->count(); expect($logs)->toBe(0); }); it('invalidates cache after revocation', function () { $this->service->provisionPackage($this->workspace, 'creator'); // Warm up cache $this->service->can($this->workspace, 'ai.credits'); // Revoke $this->service->revokePackage($this->workspace, 'creator'); // Check that revocation is reflected (cache was invalidated) $result = $this->service->can($this->workspace, 'ai.credits'); expect($result->isDenied())->toBeTrue(); }); }); describe('expireCycleBoundBoosts() method', function () { it('expires cycle-bound boosts', function () { $boost = $this->service->provisionBoost($this->workspace, 'ai.credits', [ 'limit_value' => 100, 'duration_type' => Boost::DURATION_CYCLE_BOUND, ]); $this->service->expireCycleBoundBoosts($this->workspace); $boost->refresh(); expect($boost->status)->toBe(Boost::STATUS_EXPIRED); }); it('does not expire permanent boosts', function () { $boost = $this->service->provisionBoost($this->workspace, 'ai.credits', [ 'limit_value' => 100, 'duration_type' => Boost::DURATION_PERMANENT, ]); $this->service->expireCycleBoundBoosts($this->workspace); $boost->refresh(); expect($boost->status)->toBe(Boost::STATUS_ACTIVE); }); it('creates expiration log entries', function () { $this->service->provisionBoost($this->workspace, 'ai.credits', [ 'limit_value' => 100, 'duration_type' => Boost::DURATION_CYCLE_BOUND, ]); $this->service->expireCycleBoundBoosts($this->workspace); $log = EntitlementLog::where('workspace_id', $this->workspace->id) ->where('action', EntitlementLog::ACTION_BOOST_EXPIRED) ->first(); expect($log)->not->toBeNull(); }); }); }); describe('EntitlementResult', function () { it('calculates remaining correctly', function () { $result = EntitlementResult::allowed(limit: 100, used: 75, featureCode: 'test'); expect($result->remaining)->toBe(25); }); it('calculates usage percentage correctly', function () { $result = EntitlementResult::allowed(limit: 100, used: 75, featureCode: 'test'); expect((int) $result->getUsagePercentage())->toBe(75); }); it('identifies near limit correctly', function () { $result = EntitlementResult::allowed(limit: 100, used: 85, featureCode: 'test'); expect($result->isNearLimit())->toBeTrue(); $result2 = EntitlementResult::allowed(limit: 100, used: 50, featureCode: 'test'); expect($result2->isNearLimit())->toBeFalse(); }); it('identifies unlimited correctly', function () { $result = EntitlementResult::unlimited('test'); expect($result->isUnlimited())->toBeTrue() ->and($result->isAllowed())->toBeTrue(); }); });