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