Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
95.90% covered (success)
95.90%
187 / 195
76.92% covered (warning)
76.92%
10 / 13
CRAP
0.00% covered (danger)
0.00%
0 / 1
PlanComparisonService
95.90% covered (success)
95.90%
187 / 195
76.92% covered (warning)
76.92%
10 / 13
56
0.00% covered (danger)
0.00%
0 / 1
 generateComparison
100.00% covered (success)
100.00%
27 / 27
100.00% covered (success)
100.00%
1 / 1
5
 exportToCsv
97.14% covered (success)
97.14%
34 / 35
0.00% covered (danger)
0.00%
0 / 1
12
 getPlansToCompare
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
4
 getFeaturesToCompare
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 buildComparisonMatrix
100.00% covered (success)
100.00%
21 / 21
100.00% covered (success)
100.00%
1 / 1
3
 getFeatureValueForPlan
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
1 / 1
6
 formatValueForDisplay
58.33% covered (warning)
58.33%
7 / 12
0.00% covered (danger)
0.00%
0 / 1
8.60
 areValuesEqual
80.00% covered (warning)
80.00%
8 / 10
0.00% covered (danger)
0.00%
0 / 1
7.39
 analyzeFeatureDifferences
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
4
 getHubspotStatus
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
3
 buildSummary
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
2
 formatPlansForResponse
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
1
 formatFeaturesForResponse
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3namespace App\Http\Services;
4
5use App\DTO\PlanComparisonDTO;
6use App\Http\Models\Feature;
7use App\Http\Models\Plans;
8use Illuminate\Support\Collection;
9
10/**
11 * Service for generating plan comparison reports.
12 *
13 * This service provides functionality to compare features across multiple plans,
14 * generating matrices and statistics useful for administrative dashboards.
15 */
16class PlanComparisonService
17{
18    /**
19     * Generate a comparison of features across specified plans.
20     *
21     * @param  array<string>|null  $planIds  Specific plan IDs to compare (all active if null)
22     * @param  string|null  $category  Filter features by category
23     * @param  bool  $activeOnly  Only include active plans
24     * @param  bool  $includeHubspot  Include HubSpot configuration status
25     * @return PlanComparisonDTO The comparison data
26     */
27    public function generateComparison(
28        ?array $planIds = null,
29        ?string $category = null,
30        bool $activeOnly = true,
31        bool $includeHubspot = true
32    ): PlanComparisonDTO {
33        // Get plans to compare
34        $plans = $this->getPlansToCompare($planIds, $activeOnly);
35
36        if ($plans->isEmpty()) {
37            return PlanComparisonDTO::empty();
38        }
39
40        // Get features to compare
41        $features = $this->getFeaturesToCompare($category);
42
43        if ($features->isEmpty()) {
44            return new PlanComparisonDTO(
45                plans: $this->formatPlansForResponse($plans),
46                features: [],
47                comparisonMatrix: [],
48                differences: [],
49                commonFeatures: [],
50                hubspotStatus: $includeHubspot ? $this->getHubspotStatus($plans) : [],
51                summary: $this->buildSummary($plans, collect(), [], [])
52            );
53        }
54
55        // Build the comparison matrix
56        $matrix = $this->buildComparisonMatrix($plans, $features);
57
58        // Identify common and different features
59        [$commonFeatures, $differences] = $this->analyzeFeatureDifferences($matrix, $plans);
60
61        // Get HubSpot status if requested
62        $hubspotStatus = $includeHubspot ? $this->getHubspotStatus($plans) : [];
63
64        // Build summary statistics
65        $summary = $this->buildSummary($plans, $features, $commonFeatures, $differences);
66
67        return new PlanComparisonDTO(
68            plans: $this->formatPlansForResponse($plans),
69            features: $this->formatFeaturesForResponse($features),
70            comparisonMatrix: $matrix,
71            differences: $differences,
72            commonFeatures: $commonFeatures,
73            hubspotStatus: $hubspotStatus,
74            summary: $summary
75        );
76    }
77
78    /**
79     * Export comparison data to CSV format.
80     *
81     * @param  array<string>|null  $planIds  Specific plan IDs to compare
82     * @param  string|null  $category  Filter features by category
83     * @param  bool  $activeOnly  Only include active plans
84     * @param  bool  $includeHubspot  Include HubSpot column
85     * @return array{headers: array<string>, rows: array<int, array<string>>} CSV data
86     */
87    public function exportToCsv(
88        ?array $planIds = null,
89        ?string $category = null,
90        bool $activeOnly = true,
91        bool $includeHubspot = false
92    ): array {
93        $comparison = $this->generateComparison($planIds, $category, $activeOnly, $includeHubspot);
94
95        // Build headers: Feature, Category, Type, Plan1, Plan2, ..., Common
96        $headers = ['Feature', 'Category', 'Type'];
97        $planIdentifiers = [];
98
99        foreach ($comparison->plans as $plan) {
100            $headers[] = $plan['title'];
101            $planIdentifiers[] = $plan['identifier'];
102        }
103
104        if ($includeHubspot) {
105            $headers[] = 'HubSpot Config';
106        }
107
108        $headers[] = 'Common';
109
110        // Build rows
111        $rows = [];
112
113        foreach ($comparison->comparisonMatrix as $featureKey => $data) {
114            $row = [
115                $data['feature_name'],
116                $data['category'],
117                $data['value_type'],
118            ];
119
120            // Add value for each plan
121            foreach ($planIdentifiers as $identifier) {
122                $displayValue = $data['values'][$identifier]['display'] ?? 'N/A';
123                $row[] = $displayValue;
124            }
125
126            // Add HubSpot column if requested (N/A for feature rows)
127            if ($includeHubspot) {
128                $row[] = 'N/A';
129            }
130
131            // Add common indicator
132            $row[] = $data['is_common'] ? 'Yes' : 'No';
133
134            $rows[] = $row;
135        }
136
137        // Add HubSpot status row if requested
138        if ($includeHubspot && ! empty($comparison->hubspotStatus)) {
139            $hubspotRow = ['HubSpot Configured', 'system', 'boolean'];
140
141            foreach ($planIdentifiers as $identifier) {
142                $status = $comparison->hubspotStatus[$identifier] ?? null;
143                $hubspotRow[] = $status && $status['has_config'] ? 'Yes' : 'No';
144            }
145
146            $hubspotRow[] = 'N/A';
147            $hubspotRow[] = 'N/A';
148
149            $rows[] = $hubspotRow;
150        }
151
152        return [
153            'headers' => $headers,
154            'rows' => $rows,
155        ];
156    }
157
158    /**
159     * Get plans to compare based on criteria.
160     *
161     * @param  array<string>|null  $planIds  Specific plan IDs
162     * @param  bool  $activeOnly  Only include active plans
163     * @return Collection<int, Plans>
164     */
165    private function getPlansToCompare(?array $planIds, bool $activeOnly): Collection
166    {
167        $query = Plans::with(['planFeatures.feature', 'hubspotConfig']);
168
169        if ($planIds !== null && count($planIds) > 0) {
170            $query->whereIn('_id', $planIds);
171        }
172
173        if ($activeOnly) {
174            $query->whereNull('pricing_version');
175        }
176
177        return $query->orderBy('title')->get();
178    }
179
180    /**
181     * Get features to compare.
182     *
183     * @param  string|null  $category  Optional category filter
184     * @return Collection<int, Feature>
185     */
186    private function getFeaturesToCompare(?string $category): Collection
187    {
188        $query = Feature::active()->ordered();
189
190        if ($category !== null) {
191            $query->category($category);
192        }
193
194        return $query->get();
195    }
196
197    /**
198     * Build the comparison matrix.
199     *
200     * @param  Collection<int, Plans>  $plans
201     * @param  Collection<int, Feature>  $features
202     * @return array<string, array{feature_key: string, feature_name: string, category: string, value_type: string, is_common: bool, values: array<string, array{raw: mixed, display: string}>}>
203     */
204    private function buildComparisonMatrix(Collection $plans, Collection $features): array
205    {
206        $matrix = [];
207
208        foreach ($features as $feature) {
209            $values = [];
210            $rawValues = [];
211
212            foreach ($plans as $plan) {
213                $value = $this->getFeatureValueForPlan($plan, $feature);
214                $rawValues[] = $value;
215
216                $values[$plan->identifier] = [
217                    'raw' => $value,
218                    'display' => $this->formatValueForDisplay($value, $feature->value_type),
219                ];
220            }
221
222            // Determine if all values are the same
223            $isCommon = $this->areValuesEqual($rawValues);
224
225            $matrix[$feature->key] = [
226                'feature_key' => $feature->key,
227                'feature_name' => $feature->name,
228                'category' => $feature->category,
229                'value_type' => $feature->value_type,
230                'is_common' => $isCommon,
231                'values' => $values,
232            ];
233        }
234
235        return $matrix;
236    }
237
238    /**
239     * Get the value of a feature for a specific plan.
240     */
241    private function getFeatureValueForPlan(Plans $plan, Feature $feature): mixed
242    {
243        // Check plan_features relation first
244        $planFeature = $plan->planFeatures
245            ->filter(fn ($pf) => $pf->feature && $pf->feature->key === $feature->key && $pf->is_enabled)
246            ->first();
247
248        if ($planFeature) {
249            return $planFeature->value ?? $feature->default_value;
250        }
251
252        // Check plan-level attributes
253        $planAttributes = [
254            'prompts_per_day' => $plan->prompts_per_day,
255            'flycut_deployment' => $plan->flycut_deployment,
256            'flygrammar_actions' => $plan->flygrammar_actions,
257            'user_custom_prompts' => $plan->user_custom_prompts,
258            'user_persona_available' => $plan->user_persona_available,
259            'regenerate_count' => $plan->regenerate_count,
260            'has_fly_learning' => $plan->has_fly_learning,
261            'can_disable_flygrammar' => $plan->can_disable_flygrammar,
262        ];
263
264        if (array_key_exists($feature->key, $planAttributes) && $planAttributes[$feature->key] !== null) {
265            return $planAttributes[$feature->key];
266        }
267
268        // Return feature default value
269        return $feature->default_value;
270    }
271
272    /**
273     * Format a value for display.
274     */
275    private function formatValueForDisplay(mixed $value, string $valueType): string
276    {
277        if ($value === null) {
278            return 'N/A';
279        }
280
281        // Handle arrays regardless of declared value_type to prevent "Array to string conversion" errors
282        if (is_array($value)) {
283            return match ($valueType) {
284                Feature::VALUE_TYPE_ARRAY => implode(', ', array_map(fn ($v) => is_array($v) ? json_encode($v) : (string) $v, $value)),
285                default => json_encode($value),
286            };
287        }
288
289        return match ($valueType) {
290            Feature::VALUE_TYPE_BOOLEAN => $value ? 'Yes' : 'No',
291            Feature::VALUE_TYPE_INTEGER => $value === -1 ? 'Unlimited' : (string) $value,
292            default => (string) $value,
293        };
294    }
295
296    /**
297     * Check if all values in an array are equal.
298     *
299     * @param  array<mixed>  $values
300     */
301    private function areValuesEqual(array $values): bool
302    {
303        if (count($values) <= 1) {
304            return true;
305        }
306
307        $first = $values[0];
308
309        foreach ($values as $value) {
310            // Handle array comparison
311            if (is_array($first) && is_array($value)) {
312                if ($first !== $value) {
313                    return false;
314                }
315            } elseif ($first !== $value) {
316                return false;
317            }
318        }
319
320        return true;
321    }
322
323    /**
324     * Analyze feature differences across plans.
325     *
326     * @param  array<string, array{feature_key: string, feature_name: string, category: string, value_type: string, is_common: bool, values: array<string, array{raw: mixed, display: string}>}>  $matrix
327     * @param  Collection<int, Plans>  $plans
328     * @return array{0: array<string>, 1: array<int, array{feature_key: string, feature_name: string, category: string, values: array<string, mixed>}>}
329     */
330    private function analyzeFeatureDifferences(array $matrix, Collection $plans): array
331    {
332        $commonFeatures = [];
333        $differences = [];
334
335        foreach ($matrix as $featureKey => $data) {
336            if ($data['is_common']) {
337                $commonFeatures[] = $featureKey;
338            } else {
339                $values = [];
340                foreach ($plans as $plan) {
341                    $values[$plan->identifier] = $data['values'][$plan->identifier]['raw'] ?? null;
342                }
343
344                $differences[] = [
345                    'feature_key' => $featureKey,
346                    'feature_name' => $data['feature_name'],
347                    'category' => $data['category'],
348                    'values' => $values,
349                ];
350            }
351        }
352
353        return [$commonFeatures, $differences];
354    }
355
356    /**
357     * Get HubSpot configuration status for plans.
358     *
359     * @param  Collection<int, Plans>  $plans
360     * @return array<string, array{has_config: bool, properties_count: int, name: string|null}>
361     */
362    private function getHubspotStatus(Collection $plans): array
363    {
364        $status = [];
365
366        foreach ($plans as $plan) {
367            $config = $plan->hubspotConfig;
368
369            if ($config) {
370                $propertiesCount = count($config->getPropertyMappings());
371
372                $status[$plan->identifier] = [
373                    'has_config' => true,
374                    'properties_count' => $propertiesCount,
375                    'name' => $config->name,
376                ];
377            } else {
378                $status[$plan->identifier] = [
379                    'has_config' => false,
380                    'properties_count' => 0,
381                    'name' => null,
382                ];
383            }
384        }
385
386        return $status;
387    }
388
389    /**
390     * Build summary statistics.
391     *
392     * @param  Collection<int, Plans>  $plans
393     * @param  Collection<int, Feature>  $features
394     * @param  array<string>  $commonFeatures
395     * @param  array<int, array{feature_key: string, feature_name: string, category: string, values: array<string, mixed>}>  $differences
396     * @return array{total_plans: int, total_features: int, common_features_count: int, different_features_count: int, common_percentage: float, categories_covered: array<string>}
397     */
398    private function buildSummary(
399        Collection $plans,
400        Collection $features,
401        array $commonFeatures,
402        array $differences
403    ): array {
404        $totalFeatures = $features->count();
405        $commonCount = count($commonFeatures);
406        $differentCount = count($differences);
407
408        $commonPercentage = $totalFeatures > 0
409            ? round(($commonCount / $totalFeatures) * 100, 1)
410            : 0.0;
411
412        $categories = $features->pluck('category')->unique()->values()->all();
413
414        return [
415            'total_plans' => $plans->count(),
416            'total_features' => $totalFeatures,
417            'common_features_count' => $commonCount,
418            'different_features_count' => $differentCount,
419            'common_percentage' => $commonPercentage,
420            'categories_covered' => $categories,
421        ];
422    }
423
424    /**
425     * Format plans for API response.
426     *
427     * @param  Collection<int, Plans>  $plans
428     * @return array<int, array{id: string, title: string, identifier: string, currency: string, interval: string, unit_amount: int|string}>
429     */
430    private function formatPlansForResponse(Collection $plans): array
431    {
432        return $plans->map(fn (Plans $plan) => [
433            'id' => (string) $plan->_id,
434            'title' => $plan->title,
435            'identifier' => $plan->identifier,
436            'currency' => $plan->currency,
437            'interval' => $plan->interval,
438            'unit_amount' => $plan->unit_amount,
439        ])->values()->all();
440    }
441
442    /**
443     * Format features for API response.
444     *
445     * @param  Collection<int, Feature>  $features
446     * @return array<int, array{key: string, name: string, description: string|null, value_type: string, category: string, display_order: int}>
447     */
448    private function formatFeaturesForResponse(Collection $features): array
449    {
450        return $features->map(fn (Feature $feature) => [
451            'key' => $feature->key,
452            'name' => $feature->name,
453            'description' => $feature->description,
454            'value_type' => $feature->value_type,
455            'category' => $feature->category,
456            'display_order' => $feature->display_order,
457        ])->values()->all();
458    }
459}