Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
92.93% covered (success)
92.93%
92 / 99
83.33% covered (warning)
83.33%
10 / 12
CRAP
0.00% covered (danger)
0.00%
0 / 1
PlanFeatureService
92.93% covered (success)
92.93%
92 / 99
83.33% covered (warning)
83.33%
10 / 12
46.75
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
 getFeatureValue
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 getFeatureValueByLegacyKey
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 getAllFeaturesNewFormat
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
8
 getAllFeaturesLegacyFormat
58.33% covered (warning)
58.33%
7 / 12
0.00% covered (danger)
0.00%
0 / 1
6.81
 getAllFeaturesWrappedFormat
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
2
 getFromPlanFeatures
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
5
 getFromPlanAttributes
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
3
 getFeatureDefaultValue
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getAlignmentValue
71.43% covered (warning)
71.43%
5 / 7
0.00% covered (danger)
0.00%
0 / 1
3.21
 adjustFeaturesForRewards
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
12
 isFreemiumOrStarterPlan
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3namespace App\Http\Services;
4
5use App\Http\Models\Auth\User;
6use App\Http\Models\Feature;
7use App\Http\Models\Plans;
8
9/**
10 * Service for unified plan feature access.
11 *
12 * This service provides a unified interface for accessing plan features
13 * using the new database-driven PlanFeature system.
14 *
15 * Resolution Order:
16 * 1. plan_features relation (new system)
17 * 2. Plan-level attributes (prompts_per_day, flycut_deployment, etc.)
18 * 3. Feature default_value from features collection
19 */
20class PlanFeatureService
21{
22    public function __construct(
23        private FeatureKeyMapper $keyMapper
24    ) {}
25
26    /**
27     * Get a feature value for a plan by its new key.
28     *
29     * @param  Plans  $plan  The plan to get the feature from
30     * @param  string  $key  The new-style feature key (e.g., 'bold', 'character_limit')
31     * @return mixed The feature value or null if not found
32     */
33    public function getFeatureValue(Plans $plan, string $key): mixed
34    {
35        // 1. Check plan_features relation (new system)
36        $value = $this->getFromPlanFeatures($plan, $key);
37        if ($value !== null) {
38            return $value;
39        }
40
41        // 2. Check plan-level attributes
42        $value = $this->getFromPlanAttributes($plan, $key);
43        if ($value !== null) {
44            return $value;
45        }
46
47        // 3. Get default value from Feature model
48        return $this->getFeatureDefaultValue($key);
49    }
50
51    /**
52     * Get a feature value using a legacy key.
53     *
54     * @param  Plans  $plan  The plan to get the feature from
55     * @param  string  $legacyKey  The legacy feature key (e.g., 'Bold', 'Categories')
56     * @return mixed The feature value or null if not found
57     */
58    public function getFeatureValueByLegacyKey(Plans $plan, string $legacyKey): mixed
59    {
60        // Handle alignment-specific keys specially
61        if ($this->keyMapper->isAlignmentKey($legacyKey)) {
62            return $this->getAlignmentValue($plan, $legacyKey);
63        }
64
65        $newKey = $this->keyMapper->toNewKey($legacyKey);
66
67        return $this->getFeatureValue($plan, $newKey);
68    }
69
70    /**
71     * Get all feature values in the new format.
72     *
73     * @param  Plans  $plan  The plan to get features from
74     * @param  User|null  $user  Optional user for rewards adjustments
75     * @return array<string, mixed> Key-value pairs of feature values
76     */
77    public function getAllFeaturesNewFormat(Plans $plan, ?User $user = null): array
78    {
79        $features = [];
80
81        // Get all unique new keys from the mapper
82        $keys = $this->keyMapper->getUniqueNewKeys();
83
84        foreach ($keys as $key) {
85            $features[$key] = $this->getFeatureValue($plan, $key);
86        }
87
88        // Also include any features from plan_features that might not be in the mapper
89        if ($plan->relationLoaded('planFeatures') || $plan->planFeatures()->exists()) {
90            foreach ($plan->planFeatures as $planFeature) {
91                if ($planFeature->feature && $planFeature->is_enabled) {
92                    $key = $planFeature->feature->key;
93                    $features[$key] = $planFeature->value ?? $planFeature->feature->default_value;
94                }
95            }
96        }
97
98        // Apply rewards adjustments if user provided
99        if ($user) {
100            $features = $this->adjustFeaturesForRewards($user, $features, $plan->identifier);
101        }
102
103        return $features;
104    }
105
106    /**
107     * Get all feature values in the legacy format.
108     *
109     * Returns features in the format expected by SubscriptionTrait:
110     * ['Bold' => true, 'Categories' => 5, ...]
111     *
112     * @param  Plans  $plan  The plan to get features from
113     * @param  User|null  $user  Optional user for rewards adjustments
114     * @return array<string, mixed> Legacy key-value pairs
115     */
116    public function getAllFeaturesLegacyFormat(Plans $plan, ?User $user = null): array
117    {
118        $newFeatures = $this->getAllFeaturesNewFormat($plan, $user);
119        $legacyFeatures = [];
120
121        // Map new keys back to legacy keys
122        foreach ($this->keyMapper->getAllMappings() as $legacyKey => $newKey) {
123            if (isset($newFeatures[$newKey])) {
124                // Handle alignment specially - it maps multiple legacy keys to one new key
125                if ($this->keyMapper->isAlignmentKey($legacyKey)) {
126                    $direction = $this->keyMapper->getAlignmentDirection($legacyKey);
127                    $alignments = $newFeatures[$newKey];
128
129                    if (is_array($alignments)) {
130                        $legacyFeatures[$legacyKey] = in_array($direction, $alignments, true);
131                    } else {
132                        // If alignment is a boolean, apply to all directions
133                        $legacyFeatures[$legacyKey] = (bool) $alignments;
134                    }
135                } else {
136                    $legacyFeatures[$legacyKey] = $newFeatures[$newKey];
137                }
138            }
139        }
140
141        return $legacyFeatures;
142    }
143
144    /**
145     * Get all feature values in the wrapped format for CurrentSubscriptionDTO.
146     *
147     * Returns features in the format: {key: {value: x, description: y}}
148     *
149     * @param  Plans  $plan  The plan to get features from
150     * @param  User|null  $user  Optional user for rewards adjustments
151     * @return array<string, array{value: mixed, description: string}>
152     */
153    public function getAllFeaturesWrappedFormat(Plans $plan, ?User $user = null): array
154    {
155        $features = $this->getAllFeaturesNewFormat($plan, $user);
156        $wrapped = [];
157
158        // Load feature descriptions
159        $featureModels = Feature::where('is_active', true)->get()->keyBy('key');
160
161        foreach ($features as $key => $value) {
162            $feature = $featureModels->get($key);
163            $wrapped[$key] = [
164                'value' => $value,
165                'description' => $feature?->description ?? '',
166            ];
167        }
168
169        return $wrapped;
170    }
171
172    /**
173     * Get feature value from plan_features relation.
174     */
175    private function getFromPlanFeatures(Plans $plan, string $key): mixed
176    {
177        // Ensure the relation is loaded
178        if (! $plan->relationLoaded('planFeatures')) {
179            $plan->load('planFeatures.feature');
180        }
181
182        $planFeature = $plan->planFeatures
183            ->filter(function ($pf) use ($key) {
184                return $pf->feature && $pf->feature->key === $key && $pf->is_enabled;
185            })
186            ->first();
187
188        if ($planFeature) {
189            return $planFeature->value ?? $planFeature->feature->default_value;
190        }
191
192        return null;
193    }
194
195    /**
196     * Get feature value from plan-level attributes.
197     */
198    private function getFromPlanAttributes(Plans $plan, string $key): mixed
199    {
200        $planAttributes = [
201            'prompts_per_day',
202            'flycut_deployment',
203            'flygrammar_actions',
204            'user_custom_prompts',
205            'user_persona_available',
206            'regenerate_count',
207            'has_fly_learning',
208            'can_disable_flygrammar',
209        ];
210
211        if (in_array($key, $planAttributes, true)) {
212            $value = $plan->{$key};
213            if ($value !== null) {
214                return $value;
215            }
216        }
217
218        return null;
219    }
220
221    /**
222     * Get the default value for a feature from the Feature model.
223     */
224    private function getFeatureDefaultValue(string $key): mixed
225    {
226        $feature = Feature::where('key', $key)->where('is_active', true)->first();
227
228        return $feature?->default_value;
229    }
230
231    /**
232     * Handle alignment value lookup for legacy alignment keys.
233     *
234     * Legacy system had separate keys for each alignment direction.
235     * New system has a single 'alignment' key with an array of allowed directions.
236     */
237    private function getAlignmentValue(Plans $plan, string $legacyKey): bool
238    {
239        $direction = $this->keyMapper->getAlignmentDirection($legacyKey);
240        if (! $direction) {
241            return false;
242        }
243
244        $alignments = $this->getFeatureValue($plan, 'alignment');
245
246        // If alignment is an array, check if the direction is included
247        if (is_array($alignments)) {
248            return in_array($direction, $alignments, true);
249        }
250
251        // If alignment is a boolean, it applies to all directions
252        return (bool) $alignments;
253    }
254
255    /**
256     * Adjust features based on user's rewards level.
257     *
258     * @param  User  $user  The user
259     * @param  array<string, mixed>  $features  The features to adjust
260     * @param  string  $planIdentifier  The plan identifier
261     * @return array<string, mixed> Adjusted features
262     */
263    private function adjustFeaturesForRewards(User $user, array $features, string $planIdentifier): array
264    {
265        $isRewardable = $user->rewardable ?? false;
266        $rewardsLevel = $user->rewards_level ?? 0;
267
268        if (! $isRewardable) {
269            return $features;
270        }
271
272        // Level 1+: Unlimited shortcuts
273        if ($rewardsLevel >= 1 && isset($features['shortcuts'])) {
274            $features['shortcuts'] = -1;
275        }
276
277        // Level 2+: Unlimited characters
278        if ($rewardsLevel >= 2 && isset($features['character_limit'])) {
279            $features['character_limit'] = -1;
280        }
281
282        // Level 3+: Extra category for freemium/starter plans
283        if ($rewardsLevel >= 3 && $this->isFreemiumOrStarterPlan($planIdentifier)) {
284            if (isset($features['categories_count']) && is_numeric($features['categories_count'])) {
285                $features['categories_count'] = $features['categories_count'] + 1;
286            }
287        }
288
289        // Level 4+: Unlimited templates
290        if ($rewardsLevel >= 4 && isset($features['templates'])) {
291            $features['templates'] = -1;
292        }
293
294        return $features;
295    }
296
297    /**
298     * Check if the plan is freemium or starter.
299     */
300    private function isFreemiumOrStarterPlan(string $identifier): bool
301    {
302        return in_array($identifier, [
303            Plans::FREEMIUM_IDENTIFIER,
304            Plans::STARTER_MONTHLY_IDENTIFIER,
305            Plans::STARTER_YEARLY_IDENTIFIER,
306        ], true);
307    }
308}