1 | <?php |
||||||
2 | /** |
||||||
3 | * This file implements a Subscription. |
||||||
4 | * |
||||||
5 | * @author Bilal Gultekin <[email protected]> |
||||||
6 | * @author Justin Hartman <[email protected]> |
||||||
7 | * @copyright 2019 22 Digital |
||||||
8 | * @license MIT |
||||||
9 | * @since v0.1 |
||||||
10 | */ |
||||||
11 | |||||||
12 | namespace TwentyTwoDigital\CashierFastspring; |
||||||
13 | |||||||
14 | use Carbon\Carbon; |
||||||
15 | use Exception; |
||||||
16 | use Illuminate\Database\Eloquent\Model; |
||||||
17 | use LogicException; |
||||||
18 | use TwentyTwoDigital\CashierFastspring\Fastspring\Fastspring; |
||||||
19 | |||||||
20 | /** |
||||||
21 | * This class describes a subscription. |
||||||
22 | * |
||||||
23 | * {@inheritdoc} |
||||||
24 | */ |
||||||
25 | class Subscription extends Model |
||||||
26 | { |
||||||
27 | /** |
||||||
28 | * The attributes that are not mass assignable. |
||||||
29 | * |
||||||
30 | * @var array |
||||||
31 | */ |
||||||
32 | protected $guarded = []; |
||||||
33 | |||||||
34 | /** |
||||||
35 | * The attributes that should be mutated to dates. |
||||||
36 | * |
||||||
37 | * @var array |
||||||
38 | */ |
||||||
39 | protected $dates = [ |
||||||
40 | 'created_at', 'updated_at', 'swap_at', |
||||||
41 | ]; |
||||||
42 | |||||||
43 | /** |
||||||
44 | * The date on which the billing cycle should be anchored. |
||||||
45 | * |
||||||
46 | * @var string|null |
||||||
47 | */ |
||||||
48 | protected $billingCycleAnchor = null; |
||||||
49 | |||||||
50 | /** |
||||||
51 | * Get the user that owns the subscription. |
||||||
52 | */ |
||||||
53 | 1 | public function user() |
|||||
54 | { |
||||||
55 | 1 | return $this->owner(); |
|||||
56 | } |
||||||
57 | |||||||
58 | /** |
||||||
59 | * Get periods of the subscription. |
||||||
60 | */ |
||||||
61 | 11 | public function periods() |
|||||
62 | { |
||||||
63 | 11 | return $this->hasMany('TwentyTwoDigital\CashierFastspring\SubscriptionPeriod'); |
|||||
64 | } |
||||||
65 | |||||||
66 | /** |
||||||
67 | * Get active period of the subscription. |
||||||
68 | */ |
||||||
69 | 6 | public function activePeriod() |
|||||
70 | { |
||||||
71 | 6 | return $this->hasOne('TwentyTwoDigital\CashierFastspring\SubscriptionPeriod') |
|||||
72 | 6 | ->where('start_date', '<=', Carbon::now()->format('Y-m-d H:i:s')) |
|||||
73 | 6 | ->where('end_date', '>=', Carbon::now()->format('Y-m-d H:i:s')) |
|||||
74 | 6 | ->where('type', $this->type()); |
|||||
75 | } |
||||||
76 | |||||||
77 | /** |
||||||
78 | * Get active period or retrieve the active period from fastspring and create. |
||||||
79 | * |
||||||
80 | * Note: This is not eloquent relation, it returns SubscriptionPeriod model directly. |
||||||
81 | * |
||||||
82 | * @return \TwentyTwoDigital\CashierFastspring\SubscriptionPeriod |
||||||
83 | */ |
||||||
84 | 10 | public function activePeriodOrCreate() |
|||||
85 | { |
||||||
86 | 10 | if ($this->isFastspring()) { |
|||||
87 | 5 | return $this->activeFastspringPeriodOrCreate(); |
|||||
88 | } |
||||||
89 | |||||||
90 | 5 | return $this->activeLocalPeriodOrCreate(); |
|||||
91 | } |
||||||
92 | |||||||
93 | /** |
||||||
94 | * Get active fastspring period or retrieve the active period from fastspring and create. |
||||||
95 | * |
||||||
96 | * @return \TwentyTwoDigital\CashierFastspring\SubscriptionPeriod |
||||||
97 | */ |
||||||
98 | 5 | public function activeFastspringPeriodOrCreate() |
|||||
99 | { |
||||||
100 | // activePeriod is not used on purpose |
||||||
101 | // because it caches and causes confusion |
||||||
102 | // after this method is called |
||||||
103 | 5 | $today = Carbon::today()->format('Y-m-d'); |
|||||
104 | |||||||
105 | 5 | $activePeriod = SubscriptionPeriod::where('subscription_id', $this->id) |
|||||
106 | 5 | ->where('start_date', '<=', $today) |
|||||
107 | 5 | ->where('end_date', '>=', $today) |
|||||
108 | 5 | ->where('type', 'fastspring') |
|||||
109 | 5 | ->first(); |
|||||
110 | |||||||
111 | // if there is any return it |
||||||
112 | 5 | if ($activePeriod) { |
|||||
113 | 2 | return $activePeriod; |
|||||
114 | } |
||||||
115 | |||||||
116 | 4 | return $this->createPeriodFromFastspring(); |
|||||
117 | } |
||||||
118 | |||||||
119 | /** |
||||||
120 | * Get active local period or create. |
||||||
121 | * |
||||||
122 | * @return \TwentyTwoDigital\CashierFastspring\SubscriptionPeriod |
||||||
123 | */ |
||||||
124 | 5 | public function activeLocalPeriodOrCreate() |
|||||
125 | { |
||||||
126 | // activePeriod is not used on purpose |
||||||
127 | // because it caches and causes confusion |
||||||
128 | // after this method is called |
||||||
129 | 5 | $today = Carbon::today()->format('Y-m-d'); |
|||||
130 | |||||||
131 | 5 | $activePeriod = SubscriptionPeriod::where('subscription_id', $this->id) |
|||||
132 | 5 | ->where('start_date', '<=', $today) |
|||||
133 | 5 | ->where('end_date', '>=', $today) |
|||||
134 | 5 | ->where('type', 'local') |
|||||
135 | 5 | ->first(); |
|||||
136 | |||||||
137 | // if there is any return it |
||||||
138 | 5 | if ($activePeriod) { |
|||||
139 | return $activePeriod; |
||||||
140 | } |
||||||
141 | |||||||
142 | 5 | return $this->createPeriodLocally(); |
|||||
143 | } |
||||||
144 | |||||||
145 | /** |
||||||
146 | * Create period with the information from fastspring. |
||||||
147 | * |
||||||
148 | * @return \TwentyTwoDigital\CashierFastspring\SubscriptionPeriod |
||||||
149 | */ |
||||||
150 | 4 | protected function createPeriodFromFastspring() |
|||||
151 | { |
||||||
152 | 4 | $response = Fastspring::getSubscriptionsEntries([$this->fastspring_id]); |
|||||
0 ignored issues
–
show
Bug
introduced
by
![]() |
|||||||
153 | |||||||
154 | $period = [ |
||||||
155 | // there is no info related to type in the entries endpoint |
||||||
156 | // so we assume it is regular type |
||||||
157 | // because we create first periods (especially including trial if there is any) |
||||||
158 | // at the subscription creation |
||||||
159 | 4 | 'type' => 'fastspring', |
|||||
160 | |||||||
161 | // dates |
||||||
162 | 4 | 'start_date' => $response[0]->beginPeriodDate, |
|||||
163 | 4 | 'end_date' => $response[0]->endPeriodDate, |
|||||
164 | 4 | 'subscription_id' => $this->id, |
|||||
165 | ]; |
||||||
166 | |||||||
167 | // try to find or create |
||||||
168 | 4 | return SubscriptionPeriod::firstOrCreate($period); |
|||||
169 | } |
||||||
170 | |||||||
171 | /** |
||||||
172 | * Create period for non-fastspring/local subscriptions. |
||||||
173 | * |
||||||
174 | * Simply finds latest and add its dates $interval_length * $interval_unit |
||||||
175 | * If there is no subscription period, it creates a subscription period started today |
||||||
176 | * |
||||||
177 | * @throws \Exception |
||||||
178 | * |
||||||
179 | * @return \TwentyTwoDigital\CashierFastspring\SubscriptionPeriod |
||||||
180 | */ |
||||||
181 | 5 | protected function createPeriodLocally() |
|||||
182 | { |
||||||
183 | 5 | $lastPeriod = $this->periods()->orderBy('end_date', 'desc')->first(); |
|||||
184 | 5 | $today = Carbon::today(); |
|||||
185 | |||||||
186 | // there may be times subscriptionperiods not created more than |
||||||
187 | // interval_length * interval_unit |
||||||
188 | // For this kind of situations, we should fill the blank (actually we dont |
||||||
189 | // have to but while we are calculating it is nice to save them) |
||||||
190 | do { |
||||||
191 | // add interval value to it to create next start_date |
||||||
192 | // and sub one day to get next end_date |
||||||
193 | 5 | switch ($this->interval_unit) { |
|||||
194 | // fastspring adds month without overflow |
||||||
195 | // so lets we do the same |
||||||
196 | 5 | case 'month': |
|||||
197 | 2 | $start_date = $lastPeriod |
|||||
198 | 1 | ? $lastPeriod->start_date->addMonthsNoOverflow($this->interval_length) |
|||||
199 | 2 | : Carbon::now(); |
|||||
200 | |||||||
201 | 2 | $end_date = $start_date->copy()->addMonthsNoOverflow($this->interval_length)->subDay(); |
|||||
202 | 2 | break; |
|||||
203 | |||||||
204 | 3 | case 'week': |
|||||
205 | 1 | $start_date = $lastPeriod |
|||||
206 | 1 | ? $lastPeriod->start_date->addWeeks($this->interval_length) |
|||||
207 | 1 | : Carbon::now(); |
|||||
208 | |||||||
209 | 1 | $end_date = $start_date->copy()->addWeeks($this->interval_length)->subDay(); |
|||||
210 | 1 | break; |
|||||
211 | |||||||
212 | // probably same thing with the year |
||||||
213 | 2 | case 'year': |
|||||
214 | 1 | $start_date = $lastPeriod |
|||||
215 | 1 | ? $lastPeriod->start_date->addYearsNoOverflow($this->interval_length) |
|||||
216 | 1 | : Carbon::now(); |
|||||
217 | |||||||
218 | 1 | $end_date = $start_date->copy()->addYearsNoOverflow($this->interval_length)->subDay(); |
|||||
219 | 1 | break; |
|||||
220 | |||||||
221 | default: |
||||||
222 | 1 | throw new Exception('Unexcepted interval unit: ' . $subscription->interval_unit); |
|||||
0 ignored issues
–
show
Comprehensibility
Best Practice
introduced
by
|
|||||||
223 | } |
||||||
224 | |||||||
225 | $subscriptionPeriodData = [ |
||||||
226 | 4 | 'type' => 'local', |
|||||
227 | 4 | 'start_date' => $start_date->format('Y-m-d'), |
|||||
228 | 4 | 'end_date' => $end_date->format('Y-m-d'), |
|||||
229 | 4 | 'subscription_id' => $this->id, |
|||||
230 | ]; |
||||||
231 | |||||||
232 | 4 | $lastPeriod = SubscriptionPeriod::firstOrCreate($subscriptionPeriodData); |
|||||
233 | 4 | } while (!($today->greaterThanOrEqualTo($lastPeriod->start_date) |
|||||
234 | 4 | && $today->lessThanOrEqualTo($lastPeriod->end_date) |
|||||
235 | )); |
||||||
236 | |||||||
237 | 4 | return $lastPeriod; |
|||||
238 | } |
||||||
239 | |||||||
240 | /** |
||||||
241 | * Get the model related to the subscription. |
||||||
242 | * |
||||||
243 | * @return \Illuminate\Database\Eloquent\Relations\BelongsTo |
||||||
244 | */ |
||||||
245 | 1 | public function owner() |
|||||
246 | { |
||||||
247 | 1 | $model = getenv('FASTSPRING_MODEL') ?: config('services.fastspring.model', 'App\\User'); |
|||||
248 | |||||||
249 | 1 | $model = new $model(); |
|||||
250 | |||||||
251 | 1 | return $this->belongsTo(get_class($model), $model->getForeignKey()); |
|||||
252 | } |
||||||
253 | |||||||
254 | /** |
||||||
255 | * Determine if the subscription is valid. |
||||||
256 | * This includes following states on fastspring: active, trial, overdue, canceled. |
||||||
257 | * The only state that you should stop serving is deactivated state. |
||||||
258 | * |
||||||
259 | * @return bool |
||||||
260 | */ |
||||||
261 | 5 | public function valid() |
|||||
262 | { |
||||||
263 | 5 | return !$this->deactivated(); |
|||||
264 | } |
||||||
265 | |||||||
266 | /** |
||||||
267 | * Determine if the subscription is active. |
||||||
268 | * |
||||||
269 | * @return bool |
||||||
270 | */ |
||||||
271 | 4 | public function active() |
|||||
272 | { |
||||||
273 | 4 | return $this->state == 'active'; |
|||||
274 | } |
||||||
275 | |||||||
276 | /** |
||||||
277 | * Determine if the subscription is deactivated. |
||||||
278 | * |
||||||
279 | * @return bool |
||||||
280 | */ |
||||||
281 | 5 | public function deactivated() |
|||||
282 | { |
||||||
283 | 5 | return $this->state == 'deactivated'; |
|||||
284 | } |
||||||
285 | |||||||
286 | /** |
||||||
287 | * Determine if the subscription is not paid and in wait. |
||||||
288 | * |
||||||
289 | * @return bool |
||||||
290 | */ |
||||||
291 | 4 | public function overdue() |
|||||
292 | { |
||||||
293 | 4 | return $this->state == 'overdue'; |
|||||
294 | } |
||||||
295 | |||||||
296 | /** |
||||||
297 | * Determine if the subscription is on trial. |
||||||
298 | * |
||||||
299 | * @return bool |
||||||
300 | */ |
||||||
301 | 5 | public function trial() |
|||||
302 | { |
||||||
303 | 5 | return $this->state == 'trial'; |
|||||
304 | } |
||||||
305 | |||||||
306 | /** |
||||||
307 | * Determine if the subscription is cancelled. |
||||||
308 | * |
||||||
309 | * Note: That doesn't mean you should stop serving. This state means |
||||||
310 | * user ordered to cancel at end of the billing period. |
||||||
311 | * Subscription is converted into deactivated on the start of next payment period, |
||||||
312 | * after cancelling it. |
||||||
313 | * |
||||||
314 | * @return bool |
||||||
315 | */ |
||||||
316 | 7 | public function canceled() |
|||||
317 | { |
||||||
318 | 7 | return $this->state == 'canceled'; |
|||||
319 | } |
||||||
320 | |||||||
321 | /** |
||||||
322 | * ALIASES. |
||||||
323 | */ |
||||||
324 | |||||||
325 | /** |
||||||
326 | * Alias of canceled. |
||||||
327 | * |
||||||
328 | * @return bool |
||||||
329 | */ |
||||||
330 | 2 | public function cancelled() |
|||||
331 | { |
||||||
332 | 2 | return $this->canceled(); |
|||||
333 | } |
||||||
334 | |||||||
335 | /** |
||||||
336 | * Determine if the subscription is within its trial period. |
||||||
337 | * |
||||||
338 | * @return bool |
||||||
339 | */ |
||||||
340 | 5 | public function onTrial() |
|||||
341 | { |
||||||
342 | 5 | return $this->trial(); |
|||||
343 | } |
||||||
344 | |||||||
345 | /** |
||||||
346 | * Determine if the subscription is within its grace period after cancellation. |
||||||
347 | * |
||||||
348 | * @return bool |
||||||
349 | */ |
||||||
350 | 7 | public function onGracePeriod() |
|||||
351 | { |
||||||
352 | 7 | return $this->canceled(); |
|||||
353 | } |
||||||
354 | |||||||
355 | /** |
||||||
356 | * Determine type of the subscription: fastspring, local. |
||||||
357 | * |
||||||
358 | * @return string |
||||||
359 | */ |
||||||
360 | 11 | public function type() |
|||||
361 | { |
||||||
362 | 11 | return $this->fastspring_id ? 'fastspring' : 'local'; |
|||||
363 | } |
||||||
364 | |||||||
365 | /** |
||||||
366 | * Determine if the subscription is local. |
||||||
367 | * |
||||||
368 | * @return bool |
||||||
369 | */ |
||||||
370 | 1 | public function isLocal() |
|||||
371 | { |
||||||
372 | 1 | return $this->type() == 'local'; |
|||||
373 | } |
||||||
374 | |||||||
375 | /** |
||||||
376 | * Determine if the subscription is fastspring. |
||||||
377 | * |
||||||
378 | * @return string |
||||||
379 | */ |
||||||
380 | 11 | public function isFastspring() |
|||||
381 | { |
||||||
382 | 11 | return $this->type() == 'fastspring'; |
|||||
383 | } |
||||||
384 | |||||||
385 | /** |
||||||
386 | * Swap the subscription to a new Fastspring plan. |
||||||
387 | * |
||||||
388 | * @param string $plan New plan |
||||||
389 | * @param bool $prorate Prorate |
||||||
390 | * @param int $quantity Quantity of the product |
||||||
391 | * @param array $coupons Coupons wanted to be applied |
||||||
392 | * |
||||||
393 | * @throws \Exception |
||||||
394 | * |
||||||
395 | * @return object Response of fastspring |
||||||
396 | */ |
||||||
397 | 2 | public function swap($plan, $prorate, $quantity = 1, $coupons = []) |
|||||
398 | { |
||||||
399 | 2 | $response = Fastspring::swapSubscription($this->fastspring_id, $plan, $prorate, $quantity, $coupons); |
|||||
0 ignored issues
–
show
The method
swapSubscription() does not exist on TwentyTwoDigital\Cashier...g\Fastspring\Fastspring . Since you implemented __callStatic , consider adding a @method annotation.
(
Ignorable by Annotation
)
If this is a false-positive, you can also ignore this issue in your code via the
![]() |
|||||||
400 | 2 | $status = $response->subscriptions[0]; |
|||||
0 ignored issues
–
show
|
|||||||
401 | |||||||
402 | 2 | if ($status->result == 'success') { |
|||||
403 | // we update subscription |
||||||
404 | // according to prorate value |
||||||
405 | 2 | if ($prorate) { |
|||||
406 | // if prorate is true |
||||||
407 | // the plan is changed immediately |
||||||
408 | // no need to fill swap columns |
||||||
409 | |||||||
410 | // if the plan is in the trial state |
||||||
411 | // then delete the current period |
||||||
412 | // because it will change immediately |
||||||
413 | // but period won't update because it exists |
||||||
414 | 1 | if ($this->state == 'trial') { |
|||||
415 | $activePeriod = $this->activePeriodOrCreate(); |
||||||
416 | $activePeriod->delete(); |
||||||
417 | } |
||||||
418 | |||||||
419 | 1 | $this->plan = $plan; |
|||||
420 | 1 | $this->save(); |
|||||
421 | } else { |
||||||
422 | // if prorate is false |
||||||
423 | // save plan swap_to |
||||||
424 | // because the plan will change after a while |
||||||
425 | 1 | $activePeriod = $this->activePeriodOrCreate(); |
|||||
426 | |||||||
427 | 1 | $this->swap_to = $plan; |
|||||
428 | 1 | $this->swap_at = $activePeriod |
|||||
0 ignored issues
–
show
|
|||||||
429 | 1 | ? $activePeriod->end_date |
|||||
430 | : null; |
||||||
431 | 1 | $this->save(); |
|||||
432 | } |
||||||
433 | |||||||
434 | 2 | return $this; |
|||||
435 | } |
||||||
436 | |||||||
437 | throw new Exception('Swap operation failed. Response: ' . json_encode($response)); |
||||||
438 | } |
||||||
439 | |||||||
440 | /** |
||||||
441 | * Cancel the subscription at the end of the billing period. |
||||||
442 | * |
||||||
443 | * @throws \Exception |
||||||
444 | * |
||||||
445 | * @return object Response of fastspring |
||||||
446 | */ |
||||||
447 | 2 | public function cancel() |
|||||
448 | { |
||||||
449 | 2 | $response = Fastspring::cancelSubscription($this->fastspring_id); |
|||||
0 ignored issues
–
show
The method
cancelSubscription() does not exist on TwentyTwoDigital\Cashier...g\Fastspring\Fastspring . Since you implemented __callStatic , consider adding a @method annotation.
(
Ignorable by Annotation
)
If this is a false-positive, you can also ignore this issue in your code via the
![]() |
|||||||
450 | 2 | $status = $response->subscriptions[0]; |
|||||
0 ignored issues
–
show
|
|||||||
451 | 2 | $activePeriod = $this->activePeriodOrCreate(); |
|||||
452 | |||||||
453 | 2 | if ($status->result == 'success') { |
|||||
454 | 1 | $this->state = 'canceled'; |
|||||
455 | 1 | $this->swap_at = $activePeriod |
|||||
0 ignored issues
–
show
|
|||||||
456 | 1 | ? $activePeriod->end_date |
|||||
457 | : null; |
||||||
458 | 1 | $this->save(); |
|||||
459 | |||||||
460 | 1 | return $this; |
|||||
461 | } |
||||||
462 | |||||||
463 | 1 | throw new Exception('Cancel operation failed. Response: ' . json_encode($response)); |
|||||
464 | } |
||||||
465 | |||||||
466 | /** |
||||||
467 | * Cancel the subscription immediately. |
||||||
468 | * |
||||||
469 | * @throws \Exception |
||||||
470 | * |
||||||
471 | * @return object Response of fastspring |
||||||
472 | */ |
||||||
473 | 2 | public function cancelNow() |
|||||
474 | { |
||||||
475 | 2 | $response = Fastspring::cancelSubscription($this->fastspring_id, ['billing_period' => 0]); |
|||||
476 | 2 | $status = $response->subscriptions[0]; |
|||||
0 ignored issues
–
show
|
|||||||
477 | |||||||
478 | 2 | if ($status->result == 'success') { |
|||||
479 | // if it is canceled now |
||||||
480 | // state should be deactivated |
||||||
481 | 1 | $this->state = 'deactivated'; |
|||||
482 | 1 | $this->save(); |
|||||
483 | |||||||
484 | 1 | return $this; |
|||||
485 | } |
||||||
486 | |||||||
487 | 1 | throw new Exception('CancelNow operation failed. Response: ' . json_encode($response)); |
|||||
488 | } |
||||||
489 | |||||||
490 | /** |
||||||
491 | * Resume the cancelled subscription. |
||||||
492 | * |
||||||
493 | * @throws \LogicException |
||||||
494 | * @throws \Exception |
||||||
495 | * |
||||||
496 | * @return object Response of fastspring |
||||||
497 | */ |
||||||
498 | 3 | public function resume() |
|||||
499 | { |
||||||
500 | 3 | if (!$this->onGracePeriod()) { |
|||||
501 | 1 | throw new LogicException('Unable to resume subscription that is not within grace period or not canceled.'); |
|||||
502 | } |
||||||
503 | |||||||
504 | 2 | $response = Fastspring::uncancelSubscription($this->fastspring_id); |
|||||
0 ignored issues
–
show
The method
uncancelSubscription() does not exist on TwentyTwoDigital\Cashier...g\Fastspring\Fastspring . Since you implemented __callStatic , consider adding a @method annotation.
(
Ignorable by Annotation
)
If this is a false-positive, you can also ignore this issue in your code via the
![]() |
|||||||
505 | 2 | $status = $response->subscriptions[0]; |
|||||
0 ignored issues
–
show
|
|||||||
506 | |||||||
507 | 2 | if ($status->result == 'success') { |
|||||
508 | 1 | $this->state = 'active'; |
|||||
509 | |||||||
510 | // set null swap columns |
||||||
511 | 1 | $this->swap_at = null; |
|||||
512 | 1 | $this->swap_to = null; |
|||||
513 | |||||||
514 | 1 | $this->save(); |
|||||
515 | |||||||
516 | 1 | return $this; |
|||||
517 | } |
||||||
518 | |||||||
519 | 1 | throw new Exception('Resume operation failed. Response: ' . json_encode($response)); |
|||||
520 | } |
||||||
521 | } |
||||||
522 |