Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
81.82% covered (warning)
81.82%
54 / 66
75.00% covered (warning)
75.00%
9 / 12
CRAP
0.00% covered (danger)
0.00%
0 / 1
Plans
81.82% covered (warning)
81.82%
54 / 66
75.00% covered (warning)
75.00%
9 / 12
23.65
0.00% covered (danger)
0.00%
0 / 1
 getCurrencyAttribute
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getIntervalAttribute
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getUnitAmountAttribute
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getIsActiveAttribute
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 planFeatures
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 hubspotConfig
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 subscriptions
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getByIdentifier
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
6
 clearIdentifierCache
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getPlanFeatureValue
90.91% covered (success)
90.91%
20 / 22
0.00% covered (danger)
0.00%
0 / 1
4.01
 getAllFeatureValues
100.00% covered (success)
100.00%
26 / 26
100.00% covered (success)
100.00%
1 / 1
6
 newFactory
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3namespace App\Http\Models;
4
5use Database\Factories\Http\Models\PlansFactory;
6use Illuminate\Database\Eloquent\Factories\HasFactory;
7use Illuminate\Database\Eloquent\ModelNotFoundException;
8use Illuminate\Database\Eloquent\Relations\HasMany;
9use Illuminate\Database\Eloquent\Relations\HasOne;
10use Illuminate\Support\Facades\Cache;
11
12/**
13 * Plans model for subscription plans.
14 *
15 * @property string $_id The plan ID
16 * @property string $title The plan title
17 * @property string $identifier The plan identifier
18 * @property string|null $stripe_id Stripe price ID
19 * @property string|null $stripe_product_id Stripe product ID
20 * @property bool|null $stripe_sync_enabled Whether Stripe sync is enabled
21 * @property \Carbon\Carbon|null $last_stripe_sync_at Last Stripe sync timestamp
22 * @property array|null $stripe_obj Stripe object data
23 * @property array|null $features Legacy features array (deprecated, kept for audit)
24 * @property string|null $hubspot_name HubSpot plan name
25 * @property bool $has_fly_learning Whether FlyLearning is available
26 * @property int $user_custom_prompts Custom prompts limit
27 * @property int $user_persona_available Number of personas allowed
28 * @property bool $can_disable_flygrammar Whether user can disable FlyGrammar
29 * @property int $flycut_deployment FlyCut deployment daily quota
30 * @property int $flygrammar_actions FlyGrammar actions daily quota
31 * @property int $prompts_per_day AI prompts daily quota
32 * @property int $regenerate_count AI regeneration limit
33 * @property array|null $legacy_features Legacy FlyCuts features array (deprecated, use planFeatures)
34 * @property array|null $flycuts_features Legacy FlyCuts features array (alias for legacy_features)
35 * @property string|null $pricing_version Pricing version (null for active plans)
36 * @property \Carbon\Carbon|null $created_at
37 * @property \Carbon\Carbon|null $updated_at
38 * @property-read \Illuminate\Database\Eloquent\Collection<int, PlanFeature> $planFeatures
39 * @property-read PlanHubspotConfig|null $hubspotConfig
40 */
41class Plans extends Moloquent
42{
43    use HasFactory;
44
45    /**
46     * Common plan identifiers.
47     *
48     * @deprecated These constants are deprecated. Use getByIdentifier() to fetch plans from the database.
49     *             Constants are kept temporarily for backward compatibility during migration.
50     */
51    public const FREEMIUM_IDENTIFIER = 'freemium';
52
53    public const STARTER_MONTHLY_IDENTIFIER = 'starter';
54
55    public const GROWTH_MONTHLY_IDENTIFIER = 'growth';
56
57    public const PROFESSIONAL_MONTHLY_IDENTIFIER = 'sales-pro-monthly';
58
59    public const STARTER_YEARLY_IDENTIFIER = 'starter-yearly';
60
61    public const GROWTH_YEARLY_IDENTIFIER = 'growth-yearly';
62
63    public const PROFESSIONAL_YEARLY_IDENTIFIER = 'sales-pro-yearly';
64
65    public const APPSUMO_IDENTIFIER = 'appsumo-growth-lifetime';
66
67    public const DEALFUEL_IDENTIFIER = 'dealfuel-growth-lifetime';
68
69    public const ProPlanTeamsSMB = 'pro-plan-teams-smb';
70
71    public const ProPlanTeamsENT = 'pro-plan-teams-ent';
72
73    /**
74     * The table/collection name.
75     *
76     * @var string
77     */
78    protected $table = 'plans';
79
80    /**
81     * The attributes that are mass assignable.
82     *
83     * @var array<string>
84     */
85    protected $fillable = [
86        'title',
87        'identifier',
88        'stripe_id',
89        'stripe_product_id',
90        'stripe_sync_enabled',
91        'last_stripe_sync_at',
92        'stripe_obj',
93        'features',
94        'hubspot_name',
95        'has_fly_learning',
96        'user_custom_prompts',
97        'user_persona_available',
98        'can_disable_flygrammar',
99        'flycut_deployment',
100        'flygrammar_actions',
101        'prompts_per_day',
102        'regenerate_count',
103        'flycuts_features',
104        'legacy_features',
105        'pricing_version',
106    ];
107
108    /**
109     * The attributes that should be cast.
110     *
111     * @var array<string, string>
112     */
113    protected $casts = [
114        'stripe_obj' => 'array',
115        'features' => 'array',
116        'stripe_sync_enabled' => 'boolean',
117        'has_fly_learning' => 'boolean',
118        'can_disable_flygrammar' => 'boolean',
119        'last_stripe_sync_at' => 'datetime',
120        'user_custom_prompts' => 'integer',
121        'user_persona_available' => 'integer',
122        'flycut_deployment' => 'integer',
123        'flygrammar_actions' => 'integer',
124        'prompts_per_day' => 'integer',
125        'regenerate_count' => 'integer',
126    ];
127
128    /**
129     * The attributes that should be hidden for serialization.
130     *
131     * Legacy fields and HubSpot fields are hidden from API responses.
132     * HubSpot config is now stored in the separate plan_hubspot_configs collection.
133     *
134     * @var array<string>
135     */
136    protected $hidden = [
137        'stripe_obj',
138        'features',
139        'flycuts_features',
140        'legacy_features',
141        // HubSpot fields - moved to PlanHubspotConfig model
142        'hubspot_name',
143        'hubspot_last_product',
144        'hubspot_cancel_subscription_date',
145        'hubspot_payment_status',
146        'hubspot_subscription_annual_recurring_revenue',
147        'hubspot_subscription_churn_date',
148        'hubspot_subscription_expiration_date',
149        'hubspot_subscription_frequency',
150        'hubspot_subscription_monthly_recurring_revenue',
151        'hubspot_subscription_plan_type',
152        'hubspot_subscription_start_date',
153        'hubspot_subscription_status',
154        'hubspot_subscription_status_updated_on',
155        'hubspot_user_type',
156    ];
157
158    /**
159     * The accessors to append to the model's array form.
160     *
161     * @var array<string>
162     */
163    protected $appends = ['currency', 'interval', 'unit_amount', 'is_active'];
164
165    /**
166     * Get the currency from Stripe object.
167     */
168    public function getCurrencyAttribute(): string
169    {
170        return $this->stripe_obj['currency'] ?? '';
171    }
172
173    /**
174     * Get the billing interval from Stripe object.
175     */
176    public function getIntervalAttribute(): string
177    {
178        return $this->stripe_obj['recurring']['interval'] ?? '';
179    }
180
181    /**
182     * Get the unit amount from Stripe object.
183     */
184    public function getUnitAmountAttribute(): int|string
185    {
186        return $this->stripe_obj['unit_amount'] ?? '';
187    }
188
189    /**
190     * Determine if the plan is active (no pricing version).
191     *
192     * Plans with a pricing_version are considered legacy/inactive versions.
193     */
194    public function getIsActiveAttribute(): bool
195    {
196        return $this->pricing_version === null;
197    }
198
199    /**
200     * Get the plan features (pivot records with feature data).
201     *
202     * @return HasMany<PlanFeature>
203     */
204    public function planFeatures(): HasMany
205    {
206        return $this->hasMany(PlanFeature::class, 'plan_id');
207    }
208
209    /**
210     * Get the HubSpot configuration for this plan.
211     *
212     * @return HasOne<PlanHubspotConfig>
213     */
214    public function hubspotConfig(): HasOne
215    {
216        return $this->hasOne(PlanHubspotConfig::class, 'plan_id');
217    }
218
219    /**
220     * Get the subscriptions for this plan.
221     *
222     * Links via stripe_id (Plan's Stripe price ID) to stripe_plan (Subscription's Stripe plan ID).
223     *
224     * @return HasMany<Subscription>
225     */
226    public function subscriptions(): HasMany
227    {
228        return $this->hasMany(Subscription::class, 'stripe_plan', 'stripe_id');
229    }
230
231    /**
232     * Get a plan by its identifier.
233     *
234     * @param  string  $identifier  The plan identifier
235     * @return Plans The plan
236     *
237     * @throws ModelNotFoundException If the plan is not found in the database
238     */
239    public static function getByIdentifier(string $identifier): Plans
240    {
241        $cacheKey = "plan_by_identifier_{$identifier}";
242
243        return Cache::remember($cacheKey, 3600, function () use ($identifier) {
244            $plan = static::where('identifier', $identifier)
245                ->whereNull('pricing_version')
246                ->first();
247
248            if (! $plan) {
249                throw new ModelNotFoundException("Plan with identifier '{$identifier}' not found in database.");
250            }
251
252            return $plan;
253        });
254    }
255
256    /**
257     * Clear the cache for a plan by identifier.
258     *
259     * @param  string  $identifier  The plan identifier
260     */
261    public static function clearIdentifierCache(string $identifier): void
262    {
263        Cache::forget("plan_by_identifier_{$identifier}");
264    }
265
266    /**
267     * Get the value of a feature for this plan.
268     *
269     * First checks the new plan_features relation, then falls back to
270     * plan-level attributes.
271     *
272     * @param  string  $key  The feature key
273     * @return mixed The feature value or null if not found
274     */
275    public function getPlanFeatureValue(string $key): mixed
276    {
277        // First check the new plan_features relation
278        $planFeature = $this->planFeatures()
279            ->whereHas('feature', function ($query) use ($key) {
280                $query->where('key', $key);
281            })
282            ->with('feature')
283            ->where('is_enabled', true)
284            ->first();
285
286        if ($planFeature) {
287            return $planFeature->value ?? $planFeature->feature?->default_value;
288        }
289
290        // Fallback to plan-level attributes (for AI/quota features)
291        $planAttributes = [
292            'prompts_per_day' => $this->prompts_per_day,
293            'flycut_deployment' => $this->flycut_deployment,
294            'flygrammar_actions' => $this->flygrammar_actions,
295            'user_custom_prompts' => $this->user_custom_prompts,
296            'user_persona_available' => $this->user_persona_available,
297            'regenerate_count' => $this->regenerate_count,
298            'has_fly_learning' => $this->has_fly_learning,
299            'can_disable_flygrammar' => $this->can_disable_flygrammar,
300        ];
301
302        if (array_key_exists($key, $planAttributes) && $planAttributes[$key] !== null) {
303            return $planAttributes[$key];
304        }
305
306        return null;
307    }
308
309    /**
310     * Get all feature values as a key-value map in the wrapped format.
311     *
312     * Returns features in the format: {key: {value: x, description: y}}
313     * This format is expected by CurrentSubscriptionService for backward compatibility.
314     *
315     * @return array<string, array{value: mixed, description: string}>
316     */
317    public function getAllFeatureValues(): array
318    {
319        $values = [];
320
321        // Get from plan_features relation
322        $planFeatures = $this->planFeatures()->with('feature')->where('is_enabled', true)->get();
323        foreach ($planFeatures as $planFeature) {
324            if ($planFeature->feature) {
325                $values[$planFeature->feature->key] = [
326                    'value' => $planFeature->value ?? $planFeature->feature->default_value,
327                    'description' => $planFeature->feature->description ?? '',
328                ];
329            }
330        }
331
332        // Add plan-level attributes if not already set (with descriptions)
333        $planAttributeDescriptions = [
334            'prompts_per_day' => 'Daily quota for AI prompts.',
335            'flycut_deployment' => 'Daily quota for FlyCut deployments.',
336            'flygrammar_actions' => 'Daily quota for FlyGrammar actions.',
337            'user_custom_prompts' => 'Maximum number of custom prompts allowed.',
338            'user_persona_available' => 'Number of AI personas available.',
339            'regenerate_count' => 'Maximum number of AI regenerations allowed.',
340            'has_fly_learning' => 'Whether FlyLearning is available.',
341            'can_disable_flygrammar' => 'Whether user can disable FlyGrammar.',
342        ];
343
344        foreach ($planAttributeDescriptions as $key => $description) {
345            $value = $this->{$key};
346            if (! isset($values[$key]) && $value !== null) {
347                $values[$key] = [
348                    'value' => $value,
349                    'description' => $description,
350                ];
351            }
352        }
353
354        return $values;
355    }
356
357    /**
358     * Create a new factory instance for the model.
359     *
360     * @return PlansFactory
361     */
362    protected static function newFactory()
363    {
364        return PlansFactory::new();
365    }
366}