Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
0.00% |
0 / 199 |
|
0.00% |
0 / 16 |
CRAP | |
0.00% |
0 / 1 |
StripeWebhookController | |
0.00% |
0 / 199 |
|
0.00% |
0 / 16 |
4692 | |
0.00% |
0 / 1 |
__construct | |
0.00% |
0 / 2 |
|
0.00% |
0 / 1 |
2 | |||
newSubscriptionName | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
handleCustomerSubscriptionUpdated | |
0.00% |
0 / 70 |
|
0.00% |
0 / 1 |
506 | |||
handleCustomerCreated | |
0.00% |
0 / 2 |
|
0.00% |
0 / 1 |
6 | |||
handleCustomerSubscriptionDeleted | |
0.00% |
0 / 23 |
|
0.00% |
0 / 1 |
56 | |||
handleCustomerSubscriptionCreated | |
0.00% |
0 / 51 |
|
0.00% |
0 / 1 |
240 | |||
updateSubscriptionDetails | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
handlePaymentIntentSucceeded | |
0.00% |
0 / 5 |
|
0.00% |
0 / 1 |
6 | |||
handlePaymentIntentRequiresAction | |
0.00% |
0 / 5 |
|
0.00% |
0 / 1 |
6 | |||
handlePaymentIntentPartiallyFunded | |
0.00% |
0 / 5 |
|
0.00% |
0 / 1 |
6 | |||
handlePaymentIntentPaymentFailed | |
0.00% |
0 / 5 |
|
0.00% |
0 / 1 |
6 | |||
handlePaymentIntentCanceled | |
0.00% |
0 / 6 |
|
0.00% |
0 / 1 |
6 | |||
handlePaymentIntentProcessing | |
0.00% |
0 / 5 |
|
0.00% |
0 / 1 |
6 | |||
updatePaymentStatus | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
handleCustomerSubscriptionTrialWillEnd | |
0.00% |
0 / 16 |
|
0.00% |
0 / 1 |
30 | |||
missingUser | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 |
1 | <?php |
2 | |
3 | namespace App\Http\Controllers; |
4 | |
5 | use Carbon\Carbon; |
6 | use App\Http\Models\Plans; |
7 | use App\Helpers\FlyMSGLogger; |
8 | use App\Mail\TrialExpiryMail; |
9 | use App\Http\Models\Auth\User; |
10 | use App\Mail\TrialWelcomeMail; |
11 | use App\Mail\TrialReminderMail; |
12 | use App\Http\Models\Subscription; |
13 | use App\Traits\SubscriptionTrait; |
14 | use Illuminate\Support\Facades\Log; |
15 | use Illuminate\Support\Facades\Mail; |
16 | use App\Http\Services\InstancyServiceV2; |
17 | use Stripe\Subscription as StripeSubscription; |
18 | use Symfony\Component\HttpFoundation\Response; |
19 | use App\Http\Models\Admin\Company; |
20 | use App\Http\Models\Admin\CompanyGroup; |
21 | use App\Services\Email\EmailService; |
22 | use App\Services\UserInfo\SubscriptionService; |
23 | use Laravel\Cashier\Http\Controllers\WebhookController as CashierController; |
24 | |
25 | class 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 | } |