Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
93.33% covered (success)
93.33%
112 / 120
90.00% covered (success)
90.00%
9 / 10
CRAP
0.00% covered (danger)
0.00%
0 / 1
CurrentSubscriptionService
93.33% covered (success)
93.33%
112 / 120
90.00% covered (success)
90.00%
9 / 10
26.20
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getCurrentSubscription
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 getCurrentPlan
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 invalidateCache
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 buildSubscriptionDTO
100.00% covered (success)
100.00%
53 / 53
100.00% covered (success)
100.00%
1 / 1
3
 adjustFeaturesForRewards
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
9
 isFreemiumOrStarterPlan
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 buildStatistics
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
1
 ensureReferralKey
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 calculateEndsAt
38.46% covered (danger)
38.46%
5 / 13
0.00% covered (danger)
0.00%
0 / 1
7.73
1<?php
2
3namespace App\Http\Services;
4
5use App\DTO\CurrentSubscriptionDTO;
6use App\Http\Models\Admin\CompanyLicenses;
7use App\Http\Models\Auth\User;
8use App\Http\Models\Plans;
9use App\Http\Models\Shortcut;
10use App\Http\Models\ShortcutCategory;
11use App\Http\Models\ShortcutSubCategoryLv1;
12use App\Http\Models\Subscription;
13use App\Http\Models\UserReferral;
14use App\Http\Repositories\interfaces\ISubscriptionRepository;
15use Carbon\Carbon;
16use Illuminate\Support\Facades\Cache;
17use Illuminate\Support\Facades\Config;
18
19/**
20 * Service for managing current subscription data.
21 *
22 * Handles business logic for retrieving and caching current subscription
23 * information, including plan features, statistics, and user details.
24 */
25class CurrentSubscriptionService
26{
27    /**
28     * Cache key prefix for current subscription data.
29     */
30    private const CACHE_KEY_PREFIX = 'current_subscription_';
31
32    public function __construct(
33        private ISubscriptionRepository $subscriptionRepository
34    ) {}
35
36    /**
37     * Get the current subscription data for a user.
38     *
39     * Retrieves subscription data with caching support. Statistics are cached
40     * together with plan data for optimal performance.
41     *
42     * @param  User  $user  The user to get subscription data for
43     * @return CurrentSubscriptionDTO The subscription data
44     */
45    public function getCurrentSubscription(User $user): CurrentSubscriptionDTO
46    {
47        $cacheKey = self::CACHE_KEY_PREFIX.$user->getKey();
48        $ttl = Config::get('cache.expiry', 86400);
49
50        return Cache::remember($cacheKey, $ttl, function () use ($user) {
51            return $this->buildSubscriptionDTO($user);
52        });
53    }
54
55    /**
56     * Get the current plan for a user (without statistics and caching).
57     *
58     * @param  User  $user  The user to get the plan for
59     * @return Plans The current plan
60     */
61    public function getCurrentPlan(User $user): Plans
62    {
63        $subscription = $this->subscriptionRepository->getActiveSubscription($user);
64
65        if ($subscription && $subscription->plan) {
66            return $subscription->plan;
67        }
68
69        return $this->subscriptionRepository->getFreemiumPlan();
70    }
71
72    /**
73     * Invalidate the cache for a user's subscription data.
74     *
75     * @param  string  $userId  The user ID
76     * @return bool True if cache was cleared, false otherwise
77     */
78    public function invalidateCache(string $userId): bool
79    {
80        $cacheKey = self::CACHE_KEY_PREFIX.$userId;
81
82        return Cache::forget($cacheKey);
83    }
84
85    /**
86     * Build the subscription DTO with all required data.
87     *
88     * @param  User  $user  The user
89     */
90    private function buildSubscriptionDTO(User $user): CurrentSubscriptionDTO
91    {
92        $planModel = $this->getCurrentPlan($user);
93        $currentPlan = $planModel->toArray();
94        $flycutsFeatures = $currentPlan['flycuts_features'] ?? [];
95        $subscription = $this->subscriptionRepository->getActiveSubscription($user);
96
97        $flycutsFeatures = $this->adjustFeaturesForRewards($user, $flycutsFeatures, $currentPlan['identifier']);
98        $statistics = $this->buildStatistics($user, $flycutsFeatures);
99
100        $referrals = UserReferral::firstWhere('user_id', $user->id);
101        $referralKey = $this->ensureReferralKey($user);
102
103        $usedTrial = $user->subscriptionTrials()
104            ->where('plan_identifier', $currentPlan['identifier'])
105            ->exists();
106        $alreadyUsedProTrial = $user->subscriptionTrials()->exists();
107
108        $userDetails = User::withTrashed()->firstWhere('email', $user->email);
109        $userDetails['is_poc'] = $userDetails->isPOC();
110
111        $onTrial = false;
112        $planStatus = null;
113        $endsAt = null;
114
115        if ($currentPlan['identifier'] !== Plans::FREEMIUM_IDENTIFIER && $subscription) {
116            $onTrial = $subscription->onTrial();
117            $planStatus = $subscription->stripe_status;
118            $endsAt = $this->calculateEndsAt($user, $subscription, $onTrial);
119
120            $usedTrial = $user->subscriptionTrials()
121                ->where('plan_identifier', $currentPlan['identifier'])
122                ->exists();
123        }
124
125        return new CurrentSubscriptionDTO(
126            id: (string) $planModel->getKey(),
127            title: $currentPlan['title'],
128            identifier: $currentPlan['identifier'],
129            stripe_id: $planModel->stripe_id ?? null,
130            created_at: $planModel->created_at,
131            updated_at: $planModel->updated_at,
132            currency: $currentPlan['currency'] ?? '',
133            interval: $currentPlan['interval'] ?? '',
134            unit_amount: $currentPlan['unit_amount'] ?? '',
135            flycuts_features: $flycutsFeatures,
136            statistics: $statistics,
137            referrals: $referrals?->toArray(),
138            referral_key: $referralKey,
139            on_trial: $onTrial,
140            plan_status: $planStatus,
141            ends_at: $endsAt,
142            used_trial: $usedTrial,
143            already_used_pro_trial: $alreadyUsedProTrial,
144            user_details: $userDetails->toArray(),
145            has_fly_learning: (bool) ($currentPlan['has_fly_learning'] ?? false),
146            user_custom_prompts: (int) ($currentPlan['user_custom_prompts'] ?? 0),
147            user_persona_available: (int) ($currentPlan['user_persona_available'] ?? 0),
148            can_disable_flygrammar: (bool) ($currentPlan['can_disable_flygrammar'] ?? false),
149            flycut_deployment: (int) ($currentPlan['flycut_deployment'] ?? 0),
150            flygrammar_actions: (int) ($currentPlan['flygrammar_actions'] ?? 0),
151            prompts_per_day: (int) ($currentPlan['prompts_per_day'] ?? 0),
152            regenerate_count: (int) ($currentPlan['regenerate_count'] ?? 0)
153        );
154    }
155
156    /**
157     * Adjust features based on user's rewards level.
158     *
159     * Uses the new flycuts_features structure with wrapped values.
160     *
161     * @param  array<string, array{value: mixed, description: string}>  $features
162     * @return array<string, array{value: mixed, description: string}>
163     */
164    private function adjustFeaturesForRewards(User $user, array $features, string $planIdentifier): array
165    {
166        $isRewardable = $user->rewardable ?? false;
167        $rewardsLevel = $user->rewards_level ?? 0;
168
169        if (! $isRewardable) {
170            return $features;
171        }
172
173        // Level 2+: Unlimited characters
174        if ($rewardsLevel >= 2 && isset($features['character_limit'])) {
175            $features['character_limit']['value'] = -1;
176        }
177
178        // Level 3+: Extra category for freemium/starter
179        if ($rewardsLevel >= 3 && $this->isFreemiumOrStarterPlan($planIdentifier)) {
180            $currentCategories = $features['categories_count']['value'] ?? 0;
181            if (is_numeric($currentCategories)) {
182                $features['categories_count']['value'] = $currentCategories + 1;
183            }
184        }
185
186        // Level 4+: Unlimited templates
187        if ($rewardsLevel >= 4 && isset($features['templates'])) {
188            $features['templates']['value'] = -1;
189        }
190
191        return $features;
192    }
193
194    /**
195     * Check if the plan is freemium or starter.
196     */
197    private function isFreemiumOrStarterPlan(string $identifier): bool
198    {
199        return in_array($identifier, [
200            Plans::FREEMIUM_IDENTIFIER,
201            Plans::STARTER_MONTHLY_IDENTIFIER,
202            Plans::STARTER_YEARLY_IDENTIFIER,
203        ]);
204    }
205
206    /**
207     * Build statistics array for the user.
208     *
209     * Uses the new flycuts_features structure with wrapped values.
210     *
211     * @param  array<string, array{value: mixed, description: string}>  $features
212     * @return array<string, int|string>
213     */
214    private function buildStatistics(User $user, array $features): array
215    {
216        $categoriesCount = ShortcutCategory::where('user_id', $user->id)->count();
217        $subCategoriesCount = ShortcutSubCategoryLv1::whereHas('ShortcutCategory')
218            ->where('user_id', $user->id)
219            ->count();
220        $templatesCount = Shortcut::where('user_defined', false)->count();
221        $shortcutsCount = Shortcut::where('user_defined', true)->count();
222
223        return [
224            'sub_categories' => $subCategoriesCount,
225            'sub_categories_available' => $features['subcategories_count']['value'] ?? 0,
226            'categories' => $categoriesCount,
227            'categories_available' => $features['categories_count']['value'] ?? 0,
228            'characters' => $features['character_limit']['value'] ?? 0,
229            'templates' => $templatesCount,
230            'templates_available' => $features['templates']['value'] ?? 0,
231            'shortcuts' => $shortcutsCount,
232            'shortcuts_available' => $features['shortcuts']['value'] ?? 0,
233        ];
234    }
235
236    /**
237     * Ensure user has a referral key, creating one if needed.
238     */
239    private function ensureReferralKey(User $user): string
240    {
241        $referralKey = $user->referral_key;
242
243        if (! $referralKey) {
244            $referralKey = uniqid();
245            User::where('_id', $user->getKey())->update([
246                'referral_key' => $referralKey,
247            ]);
248        }
249
250        return $referralKey;
251    }
252
253    /**
254     * Calculate when the subscription ends.
255     */
256    private function calculateEndsAt(User $user, Subscription $subscription, bool $onTrial): mixed
257    {
258        $endsAt = $onTrial
259            ? $subscription->trial_ends_at
260            : ($subscription->ends_at ?? null);
261
262        if ($user->company_id) {
263            $companyLicense = CompanyLicenses::where('company_id', $user->company_id)
264                ->active()
265                ->first();
266
267            if ($companyLicense) {
268                $endsAt = Carbon::createFromFormat(
269                    'Y-m-d H:i:s',
270                    $companyLicense->contract_end_date
271                );
272            }
273        }
274
275        return $endsAt;
276    }
277}