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