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