Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
39.71% covered (danger)
39.71%
27 / 68
35.71% covered (danger)
35.71%
5 / 14
CRAP
0.00% covered (danger)
0.00%
0 / 1
PlanService
39.71% covered (danger)
39.71%
27 / 68
35.71% covered (danger)
35.71%
5 / 14
150.25
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
 getAll
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 getAllActive
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 getById
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 getByIdentifier
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getWithFeatures
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 create
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
2.03
 update
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
2.03
 delete
41.67% covered (danger)
41.67%
5 / 12
0.00% covered (danger)
0.00%
0 / 1
7.18
 assignFeatures
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 syncToStripe
0.00% covered (danger)
0.00%
0 / 22
0.00% covered (danger)
0.00%
0 / 1
20
 getFeatureValue
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 invalidateCaches
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 invalidatePlanCache
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2
3namespace App\Http\Services;
4
5use App\Http\Models\Plans;
6use App\Http\Repositories\interfaces\IPlanFeatureRepository;
7use App\Http\Repositories\interfaces\IPlanRepository;
8use App\Http\Repositories\interfaces\ISubscriptionRepository;
9use Carbon\Carbon;
10use Illuminate\Contracts\Pagination\LengthAwarePaginator;
11use Illuminate\Support\Collection;
12use Illuminate\Support\Facades\Cache;
13use Illuminate\Support\Facades\Log;
14
15/**
16 * Service for plan business logic.
17 *
18 * This service handles all business logic related to plans,
19 * coordinating with repositories and Stripe for data access and sync.
20 */
21class PlanService
22{
23    /**
24     * Cache key prefix for plans.
25     */
26    private const CACHE_KEY_PREFIX = 'plans:';
27
28    /**
29     * Cache TTL in seconds (1 hour).
30     */
31    private const CACHE_TTL = 3600;
32
33    public function __construct(
34        private IPlanRepository $planRepository,
35        private IPlanFeatureRepository $planFeatureRepository,
36        private ISubscriptionRepository $subscriptionRepository,
37        private StripeService $stripeService
38    ) {}
39
40    /**
41     * Get all plans with optional filtering and pagination.
42     *
43     * When includeSubscriptionCounts is true, each plan in the result will have
44     * an active_subscriptions_count attribute set with the count of active subscriptions.
45     *
46     * @param  string|null  $filter  Optional search filter for plan titles
47     * @param  int  $perPage  Number of items per page
48     * @param  bool  $includeSubscriptionCounts  Whether to include active subscription counts per plan
49     * @return LengthAwarePaginator<Plans>
50     */
51    public function getAll(
52        ?string $filter = null,
53        int $perPage = 15,
54        bool $includeSubscriptionCounts = false
55    ): LengthAwarePaginator {
56        $plans = $this->planRepository->getAllPaginated($filter, $perPage);
57
58        if ($includeSubscriptionCounts) {
59            $subscriptionCounts = $this->subscriptionRepository->getActiveSubscriptionCountsByPlan();
60
61            // Attach count to each plan
62            foreach ($plans as $plan) {
63                $plan->active_subscriptions_count = $subscriptionCounts[$plan->stripe_id] ?? 0;
64            }
65        }
66
67        return $plans;
68    }
69
70    /**
71     * Get all active plans (where pricing_version is null).
72     *
73     * @return Collection<int, Plans> Collection of active plans
74     */
75    public function getAllActive(): Collection
76    {
77        return Cache::remember(self::CACHE_KEY_PREFIX.'all_active', self::CACHE_TTL, function () {
78            return $this->planRepository->getAllActive();
79        });
80    }
81
82    /**
83     * Get a plan by its ID.
84     *
85     * @param  string  $id  The plan ID
86     * @return Plans|null The plan or null if not found
87     */
88    public function getById(string $id): ?Plans
89    {
90        return Cache::remember(self::CACHE_KEY_PREFIX.$id, self::CACHE_TTL, function () use ($id) {
91            return $this->planRepository->findById($id);
92        });
93    }
94
95    /**
96     * Get a plan by its identifier.
97     *
98     * @param  string  $identifier  The plan identifier
99     * @return Plans|null The plan or null if not found
100     */
101    public function getByIdentifier(string $identifier): ?Plans
102    {
103        return $this->planRepository->findByIdentifier($identifier);
104    }
105
106    /**
107     * Get a plan with its features loaded.
108     *
109     * @param  string  $id  The plan ID
110     * @return Plans|null The plan with features or null if not found
111     */
112    public function getWithFeatures(string $id): ?Plans
113    {
114        return Cache::remember(self::CACHE_KEY_PREFIX.$id.':features', self::CACHE_TTL, function () use ($id) {
115            return $this->planRepository->getWithFeatures($id);
116        });
117    }
118
119    /**
120     * Create a new plan.
121     *
122     * If stripe_sync_enabled is true, also creates a Stripe product.
123     *
124     * @param  array<string, mixed>  $data  The plan data
125     * @return Plans The created plan
126     */
127    public function create(array $data): Plans
128    {
129        $plan = $this->planRepository->create($data);
130
131        // Sync to Stripe if enabled
132        if ($plan->stripe_sync_enabled) {
133            $this->syncToStripe($plan);
134        }
135
136        $this->invalidateCaches();
137
138        return $plan;
139    }
140
141    /**
142     * Update an existing plan.
143     *
144     * If stripe_sync_enabled is true, also updates the Stripe product.
145     *
146     * @param  Plans  $plan  The plan to update
147     * @param  array<string, mixed>  $data  The update data
148     * @return Plans The updated plan
149     */
150    public function update(Plans $plan, array $data): Plans
151    {
152        $plan = $this->planRepository->update($plan, $data);
153
154        // Sync to Stripe if enabled
155        if ($plan->stripe_sync_enabled) {
156            $this->syncToStripe($plan);
157        }
158
159        $this->invalidateCaches();
160
161        return $plan;
162    }
163
164    /**
165     * Delete a plan.
166     *
167     * If archiveStripe is true and plan has a Stripe product, archives it.
168     *
169     * @param  Plans  $plan  The plan to delete
170     * @param  bool  $archiveStripe  Whether to archive the Stripe product (default: true)
171     * @return bool True if deleted successfully
172     */
173    public function delete(Plans $plan, bool $archiveStripe = true): bool
174    {
175        // Archive Stripe product if requested and exists
176        if ($archiveStripe && $plan->stripe_product_id) {
177            try {
178                $this->stripeService->archiveProduct($plan->stripe_product_id);
179            } catch (\Exception $e) {
180                Log::warning('Failed to archive Stripe product during plan deletion', [
181                    'plan_id' => (string) $plan->_id,
182                    'stripe_product_id' => $plan->stripe_product_id,
183                    'error' => $e->getMessage(),
184                ]);
185            }
186        }
187
188        // Remove all feature assignments
189        $this->planFeatureRepository->removeAllFeaturesFromPlan((string) $plan->_id);
190
191        $result = $this->planRepository->delete($plan);
192
193        $this->invalidateCaches();
194
195        return $result;
196    }
197
198    /**
199     * Assign features to a plan.
200     *
201     * @param  Plans  $plan  The plan to assign features to
202     * @param  array<array{feature_id: string, value: mixed, is_enabled?: bool}>  $features  Array of feature assignments
203     * @return Plans The plan with updated features
204     */
205    public function assignFeatures(Plans $plan, array $features): Plans
206    {
207        $this->planFeatureRepository->syncFeatures((string) $plan->_id, $features);
208
209        $this->invalidateCaches();
210
211        // Return fresh plan with features
212        return $this->planRepository->getWithFeatures((string) $plan->_id);
213    }
214
215    /**
216     * Sync a plan to Stripe.
217     *
218     * Creates a new Stripe product if one doesn't exist, or updates the existing one.
219     *
220     * @param  Plans  $plan  The plan to sync
221     * @return Plans The plan with updated Stripe data
222     */
223    public function syncToStripe(Plans $plan): Plans
224    {
225        try {
226            $result = $this->stripeService->syncPlanToStripe($plan);
227
228            // Update plan with Stripe product ID if newly created
229            if ($result['created'] && $result['product']) {
230                $plan = $this->planRepository->update($plan, [
231                    'stripe_product_id' => $result['product']->id,
232                    'last_stripe_sync_at' => Carbon::now(),
233                ]);
234            } else {
235                $plan = $this->planRepository->update($plan, [
236                    'last_stripe_sync_at' => Carbon::now(),
237                ]);
238            }
239
240            Log::info('Plan synced to Stripe', [
241                'plan_id' => (string) $plan->_id,
242                'stripe_product_id' => $plan->stripe_product_id,
243                'created' => $result['created'],
244            ]);
245        } catch (\Exception $e) {
246            Log::error('Failed to sync plan to Stripe', [
247                'plan_id' => (string) $plan->_id,
248                'error' => $e->getMessage(),
249            ]);
250
251            throw $e;
252        }
253
254        $this->invalidateCaches();
255
256        return $plan;
257    }
258
259    /**
260     * Get the value of a feature for a plan.
261     *
262     * @param  string  $planId  The plan ID
263     * @param  string  $featureKey  The feature key
264     * @return mixed The feature value or null if not found
265     */
266    public function getFeatureValue(string $planId, string $featureKey): mixed
267    {
268        return $this->planFeatureRepository->getFeatureValue($planId, $featureKey);
269    }
270
271    /**
272     * Invalidate all plan-related caches.
273     */
274    public function invalidateCaches(): void
275    {
276        Cache::forget(self::CACHE_KEY_PREFIX.'all_active');
277
278        // Note: Individual plan caches use wildcard pattern which isn't directly supported
279        // In production, consider using Redis SCAN or tags for more efficient cache invalidation
280    }
281
282    /**
283     * Invalidate cache for a specific plan.
284     *
285     * @param  string  $planId  The plan ID
286     */
287    public function invalidatePlanCache(string $planId): void
288    {
289        Cache::forget(self::CACHE_KEY_PREFIX.$planId);
290        Cache::forget(self::CACHE_KEY_PREFIX.$planId.':features');
291    }
292}