Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 199
0.00% covered (danger)
0.00%
0 / 16
CRAP
0.00% covered (danger)
0.00%
0 / 1
StripeWebhookController
0.00% covered (danger)
0.00%
0 / 199
0.00% covered (danger)
0.00%
0 / 16
4692
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 newSubscriptionName
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 handleCustomerSubscriptionUpdated
0.00% covered (danger)
0.00%
0 / 70
0.00% covered (danger)
0.00%
0 / 1
506
 handleCustomerCreated
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
6
 handleCustomerSubscriptionDeleted
0.00% covered (danger)
0.00%
0 / 23
0.00% covered (danger)
0.00%
0 / 1
56
 handleCustomerSubscriptionCreated
0.00% covered (danger)
0.00%
0 / 51
0.00% covered (danger)
0.00%
0 / 1
240
 updateSubscriptionDetails
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 handlePaymentIntentSucceeded
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
 handlePaymentIntentRequiresAction
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
 handlePaymentIntentPartiallyFunded
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
 handlePaymentIntentPaymentFailed
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
 handlePaymentIntentCanceled
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
6
 handlePaymentIntentProcessing
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
 updatePaymentStatus
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 handleCustomerSubscriptionTrialWillEnd
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
30
 missingUser
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2
3namespace App\Http\Controllers;
4
5use Carbon\Carbon;
6use App\Http\Models\Plans;
7use App\Helpers\FlyMSGLogger;
8use App\Mail\TrialExpiryMail;
9use App\Http\Models\Auth\User;
10use App\Mail\TrialWelcomeMail;
11use App\Mail\TrialReminderMail;
12use App\Http\Models\Subscription;
13use App\Traits\SubscriptionTrait;
14use Illuminate\Support\Facades\Log;
15use Illuminate\Support\Facades\Mail;
16use App\Http\Services\InstancyServiceV2;
17use Stripe\Subscription as StripeSubscription;
18use Symfony\Component\HttpFoundation\Response;
19use App\Http\Models\Admin\Company;
20use App\Http\Models\Admin\CompanyGroup;
21use App\Services\Email\EmailService;
22use App\Services\UserInfo\SubscriptionService;
23use Laravel\Cashier\Http\Controllers\WebhookController as CashierController;
24
25class StripeWebhookController extends CashierController
26{
27
28    use SubscriptionTrait;
29
30    /**
31     * The instancy service implementation.
32     *
33     * @var InstancyServiceV2
34     */
35    protected $instancyService;
36
37    protected $current_plan;
38
39    /**
40     * Create a new controller instance.
41     *
42     * @param  InstancyServiceV2 $instancyService
43     * @return void
44     */
45    public function __construct(
46        InstancyServiceV2 $instancyService,
47        private readonly SubscriptionService $subscriptionService,
48        private readonly EmailService $emailService
49    ) {
50        parent::__construct();
51        $this->instancyService = $instancyService;
52    }
53
54    protected function newSubscriptionName(array $payload)
55    {
56        return 'main';
57    }
58
59    /**
60     * Handle customer subscription updated.
61     */
62    protected function handleCustomerSubscriptionUpdated(array $payload): Response
63    {
64        $stripeId = $payload['data']['object']['customer'];
65
66        $user = $this->getUserByStripeId($stripeId);
67
68        if ($user) {
69            $data = $payload['data']['object'];
70
71            $user->subscriptions->filter(function (Subscription $subscription) use ($data) {
72                return $subscription->stripe_id === $data['id'];
73            })->each(function (Subscription $subscription) use ($data, $user, $payload) {
74                // $oldPlan = $subscription->stripe_plan;
75                // $firstItem = $data['items']['data'][0];
76                // $newPlan = $firstItem['plan']['id'];
77
78                // if ($oldPlan !== $newPlan) {
79                //     $subscription->stripe_plan = $newPlan;
80
81                // }
82
83                if (
84                    isset($data['status']) &&
85                    $data['status'] === StripeSubscription::STATUS_INCOMPLETE_EXPIRED
86                ) {
87                    $subscription->items()->delete();
88                    $subscription->delete();
89                    return;
90                }
91
92                $firstItem = $data['items']['data'][0];
93                $isSinglePlan = count($data['items']['data']) === 1;
94
95                // Plan...
96                $subscription->stripe_plan = $isSinglePlan ? $firstItem['plan']['id'] : null;
97
98                // Quantity...
99                $subscription->quantity = $isSinglePlan && isset($firstItem['quantity']) ? $firstItem['quantity'] : null;
100
101                // Trial ending date...
102                if (isset($data['trial_end'])) {
103                    $trialEnd = Carbon::createFromTimestamp($data['trial_end']);
104
105                    if (!$subscription->trial_ends_at || $subscription->trial_ends_at->ne($trialEnd)) {
106                        $subscription->trial_ends_at = $trialEnd;
107                    }
108                }
109
110                // Cancellation date...
111                if (isset($data['cancel_at_period_end'])) {
112                    if ($data['cancel_at_period_end']) {
113                        $subscription->ends_at = $subscription->onTrial()
114                            ? $subscription->trial_ends_at
115                            : Carbon::createFromTimestamp($data['current_period_end']);
116                    } elseif (isset($data['cancel_at'])) {
117                        $subscription->ends_at = Carbon::createFromTimestamp($data['cancel_at']);
118                    } else {
119                        $subscription->ends_at = null;
120                    }
121                }
122
123                // Status...
124                if (isset($data['status'])) {
125                    $subscription->stripe_status = $data['status'];
126                }
127
128                $subscription->save();
129
130                if ($subscription->onTrial()) {
131                    $user->subscriptionTrials()->create([
132                        'plan_identifier' => $subscription->plan->identifier,
133                        'trial_start_date' => $subscription->created_at,
134                        'trial_end_date'  => $subscription->trial_ends_at
135                    ]);
136
137                    $planId = Plans::select('stripe_id')->whereNull('pricing_version')->firstWhere('identifier', 'sales-pro-monthly')->stripe_id;
138
139                    if ($subscription->stripe_plan == $planId) {
140                        $this->emailService->send(
141                            $user->email,
142                            new TrialWelcomeMail($user, $subscription->trial_ends_at),
143                            'trial_welcome'
144                        );
145                    }
146                }
147
148                // Update subscription items...
149                if (isset($data['items'])) {
150                    $plans = [];
151
152                    foreach ($data['items']['data'] as $item) {
153                        $plans[] = $item['plan']['id'];
154
155                        $subscription->items()->updateOrCreate([
156                            'stripe_id' => $item['id'],
157                        ], [
158                            'stripe_plan' => $item['plan']['id'],
159                            'quantity' => $item['quantity'] ?? null,
160                        ]);
161                    }
162
163                    // Delete items that aren't attached to the subscription anymore...
164                    $subscription->items()->whereNotIn('stripe_plan', $plans)->delete();
165                }
166            });
167
168            //To ensure other subscriptions are canceled now
169            $user->subscriptions->filter(function (Subscription $subscription) use ($data) {
170                return $subscription->stripe_id !== $data['id'];
171            })->each(function (Subscription $subscription) {
172                try {
173                    $subscription->cancelNow();
174                } catch (\Throwable $th) {
175                    // Send notifications to developers
176                    FlyMSGLogger::logError("StripeWebhookController::handleCustomerSubscriptionUpdated:cancelNow", $th);
177                }
178            });
179
180            // If plan is pro plan after the update then update the membership end_date on instancy
181            if (in_array($this->getCurrentPlan($user)->identifier, [Plans::PROFESSIONAL_MONTHLY_IDENTIFIER, Plans::PROFESSIONAL_YEARLY_IDENTIFIER])) {
182                // Update Instancy with new subscription ending date
183                try {
184                    $current_period_end = Carbon::createFromTimestamp($data['current_period_end']);
185                    $this->instancyService->updateMembership($user->email, $current_period_end->toDateString());
186                } catch (\Throwable $th) {
187                    FlyMSGLogger::logError("StripeWebhookController::handleCustomerSubscriptionUpdated:InstancyResponse", $th);
188                }
189            }
190
191            return $this->successMethod();
192        }
193
194        return $this->missingUser($stripeId);
195    }
196
197    protected function handleCustomerCreated(array $payload)
198    {
199        if ($this->getUserByStripeId($payload['data']['object']['id'])) {
200            return $this->successMethod();
201        }
202    }
203
204    /**
205     * Handle a cancelled customer from a Stripe subscription.
206     */
207    protected function handleCustomerSubscriptionDeleted(array $payload): Response
208    {
209        $stripeId = $payload['data']['object']['customer'];
210
211        $user = $this->getUserByStripeId($stripeId);
212        if ($user) {
213            $planId = Plans::select('stripe_id')->whereNull('pricing_version')->firstWhere('identifier', 'sales-pro-monthly')->stripe_id;
214            $user->subscriptions->filter(function ($subscription) use ($payload) {
215                return $subscription->stripe_id === $payload['data']['object']['id'];
216            })->each(function ($subscription) use ($user, $planId) {
217                $subscription->markAsCancelled();
218                // This checks if this a sales pro trial plan
219                // If the subscription was canceled because it was a sales pro monthly 14 day trial that ended after 14 days then send a mail
220                if (filled($subscription->trial_ends_at) && $subscription->stripe_plan == $planId) {
221                    if (!$subscription->trial_ends_at->isFuture()) {
222                        $this->emailService->send(
223                            $user->email,
224                            new TrialExpiryMail($user),
225                            'trial_expiry'
226                        );
227                    }
228                }
229
230                // Cancel instancy if the users plan is pro plan.
231                // $subscription is no longer valid at this point
232                // as it has been markAsCancelled therefore getCurrentPlan($user) won't match $subscription
233                if (in_array($subscription->plan->identifier, [Plans::PROFESSIONAL_MONTHLY_IDENTIFIER, Plans::PROFESSIONAL_YEARLY_IDENTIFIER])) {
234                    try {
235                        $end_date = $subscription->ends_at->subDay()->toDateString();
236                        $this->instancyService->updateMembership($user->email, $end_date);
237                    } catch (\Throwable $th) {
238                        FlyMSGLogger::logError("StripeWebhookController::handleCustomerSubscriptionDeleted:InstancyResponse", $th);
239                    }
240                }
241            });
242
243            return $this->successMethod();
244        }
245
246        return $this->missingUser($stripeId);
247    }
248
249    /**
250     * Handle customer subscription created.
251     */
252    protected function handleCustomerSubscriptionCreated(array $payload): Response
253    {
254        $stripeId = $payload['data']['object']['customer'];
255        $user = $this->getUserByStripeId($stripeId);
256
257        if ($user) {
258            $data = $payload['data']['object'];
259
260            if (!$user->subscriptions->contains('stripe_id', $data['id'])) {
261                if (isset($data['trial_end'])) {
262                    $trialEndsAt = Carbon::createFromTimestamp($data['trial_end']);
263                } else {
264                    $trialEndsAt = null;
265                }
266
267                $firstItem = $data['items']['data'][0];
268                $isSinglePlan = count($data['items']['data']) === 1;
269
270                $subscription = $user->subscriptions()->create([
271                    'name' => $data['metadata']['name'] ?? $this->newSubscriptionName($payload),
272                    'stripe_id' => $data['id'],
273                    'stripe_status' => $data['status'],
274                    'stripe_plan' => $isSinglePlan ? $firstItem['plan']['id'] : null,
275                    'quantity' => $isSinglePlan && isset($firstItem['quantity']) ? $firstItem['quantity'] : null,
276                    'trial_ends_at' => $trialEndsAt,
277                    'ends_at' => null,
278                ]);
279
280                foreach ($data['items']['data'] as $item) {
281                    $subscription->items()->create([
282                        'stripe_id' => $item['id'],
283                        'stripe_plan' => $item['plan']['id'],
284                        'quantity' => $item['quantity'] ?? null,
285                    ]);
286                }
287            }
288
289            if ($this->getCurrentPlan($user)->identifier == 'sales-pro-monthly' || $this->getCurrentPlan($user)->identifier == 'sales-pro-yearly') {
290                try {
291                    $group = CompanyGroup::find($user->company_group_id);
292                    $company = Company::find($user->company_id);
293
294                    $groupId = false;
295                    if ($company) {
296                        $groupId = $company->instancy_id;
297                    } elseif ($group) {
298                        $groupId = $group->instancy_id;
299                    }
300
301                    $response = $this->instancyService->createInstancyUser($user->email, $groupId);
302                } catch (\Throwable $th) {
303                    FlyMSGLogger::logError("StripeWebhookController::handleCustomerSubscriptionCreated:InstancyResponse", $th);
304                }
305            }
306
307            if (isset($trialEndsAt)) {
308                $user->subscriptionTrials()->create([
309                    'plan_identifier' => $subscription->plan->identifier,
310                    'trial_start_date' => $subscription->created_at,
311                    'trial_end_date'  => $subscription->trial_ends_at
312                ]);
313
314                $planId = Plans::select('stripe_id')->whereNull('pricing_version')->firstWhere('identifier', 'sales-pro-monthly')->stripe_id;
315
316                if ($subscription->stripe_plan == $planId) {
317                    $this->emailService->send(
318                        $user->email,
319                        new TrialWelcomeMail($user, $subscription->trial_ends_at),
320                        'trial_welcome'
321                    );
322                }
323            }
324
325            return $this->successMethod();
326        }
327
328        return $this->missingUser($stripeId);
329    }
330
331    protected function updateSubscriptionDetails(User $user, $payload)
332    {
333        $this->current_plan = $this->getCurrentPlan($user);
334    }
335
336    protected function handlePaymentIntentSucceeded(array $payload)
337    {
338        $stripeId = $payload['data']['object']['customer'];
339
340        if ($user = $this->getUserByStripeId($stripeId)) {
341            $this->updatePaymentStatus($user, $payload['data']['object']['status']);
342
343            return $this->successMethod();
344        }
345
346        return $this->missingUser($stripeId);
347    }
348
349    protected function handlePaymentIntentRequiresAction(array $payload)
350    {
351        $stripeId = $payload['data']['object']['customer'];
352
353        if ($user = $this->getUserByStripeId($stripeId)) {
354            $this->updatePaymentStatus($user, $payload['data']['object']['status']);
355
356            return $this->successMethod();
357        }
358
359        return $this->missingUser($stripeId);
360    }
361
362    protected function handlePaymentIntentPartiallyFunded(array $payload)
363    {
364        $stripeId = $payload['data']['object']['customer'];
365
366        if ($user = $this->getUserByStripeId($stripeId)) {
367            $this->updatePaymentStatus($user, $payload['data']['object']['status']);
368
369            return $this->successMethod();
370        }
371
372        return $this->missingUser($stripeId);
373    }
374
375    protected function handlePaymentIntentPaymentFailed(array $payload)
376    {
377        $stripeId = $payload['data']['object']['customer'];
378
379        if ($user = $this->getUserByStripeId($stripeId)) {
380            $this->updatePaymentStatus($user, $payload['data']['object']['status']);
381
382            return $this->successMethod();
383        }
384
385        return $this->missingUser($stripeId);
386    }
387
388    protected function handlePaymentIntentCanceled(array $payload)
389    {
390        $stripeId = $payload['data']['object']['customer'];
391
392        $user = $this->getUserByStripeId($stripeId);
393        if ($user) {
394            $this->updatePaymentStatus($user, $payload['data']['object']['status']);
395            return $this->successMethod();
396        }
397
398        return $this->missingUser($stripeId);
399    }
400
401    protected function handlePaymentIntentProcessing(array $payload)
402    {
403        $stripeId = $payload['data']['object']['customer'];
404
405        if ($user = $this->getUserByStripeId($stripeId)) {
406            $this->updatePaymentStatus($user, $payload['data']['object']['status']);
407
408            return $this->successMethod();
409        }
410
411        return $this->missingUser($stripeId);
412    }
413
414    protected function updatePaymentStatus(User $user, $status) {}
415
416    protected function handleCustomerSubscriptionTrialWillEnd(array $payload)
417    {
418        $stripeId = $payload['data']['object']['customer'];
419        $user = $this->getUserByStripeId($stripeId);
420
421        if (empty($user)) {
422            return $this->missingUser($stripeId);
423        }
424
425        $subscription = $user->subscriptions()->latest()->first();
426
427        if (empty($subscription)) {
428            return;
429        }
430
431        $planId = Plans::select('stripe_id')->whereNull('pricing_version')->firstWhere('identifier', 'sales-pro-monthly')->stripe_id;
432
433        if (!$subscription->onTrial() || $subscription->stripe_plan !== $planId) {
434            return;
435        }
436
437        $this->emailService->send(
438            $user->email,
439            new TrialReminderMail($user),
440            'trial_reminder'
441        );
442
443        return $this->successMethod();
444    }
445
446    protected function missingUser($stripeId)
447    {
448        return new Response("User with stripe id {$stripeId} doesn't exist on the app", 200);
449    }
450}