Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
21.74% covered (danger)
21.74%
70 / 322
49.30% covered (danger)
49.30%
35 / 71
CRAP
0.00% covered (danger)
0.00%
0 / 1
Subscription
21.74% covered (danger)
21.74%
70 / 322
49.30% covered (danger)
49.30%
35 / 71
8105.52
0.00% covered (danger)
0.00%
0 / 1
 boot
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
1.02
 user
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 owner
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 items
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 hasMultiplePlans
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 hasSinglePlan
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 hasPlan
40.00% covered (danger)
40.00%
2 / 5
0.00% covered (danger)
0.00%
0 / 1
2.86
 findItemOrFail
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 valid
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
3
 incomplete
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 scopeIncomplete
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 pastDue
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 scopePastDue
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 active
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
7
 canceled
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 scopeActive
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
2
 syncStripeStatus
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 recurring
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 scopeRecurring
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 cancelled
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 scopeCancelled
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 scopeNotCancelled
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 ended
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 scopeEnded
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 onTrial
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 scopeOnTrial
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 scopeNotOnTrial
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 onGracePeriod
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 proTeamEnded
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 scopeOnGracePeriod
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 scopeNotOnGracePeriod
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 incrementQuantity
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
6
 incrementAndInvoice
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 decrementQuantity
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
6
 updateQuantity
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
6
 reportUsage
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 reportUsageFor
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 usageRecords
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 usageRecordsFor
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 anchorBillingCycleOn
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 skipTrial
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 endTrial
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
6
 extendTrial
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
6
 swap
0.00% covered (danger)
0.00%
0 / 33
0.00% covered (danger)
0.00%
0 / 1
42
 swapAndInvoice
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 parseSwapPlans
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
30
 mergeItemsThatShouldBeDeletedDuringSwap
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
20
 getSwapOptions
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
20
 addPlan
0.00% covered (danger)
0.00%
0 / 23
0.00% covered (danger)
0.00%
0 / 1
12
 addPlanAndInvoice
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 removePlan
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
20
 cancel
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
6
 cancelAt
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
6
 cancelNow
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
 cancelNowAndInvoice
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
2
 markAsCancelled
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 markAsCancelledOnlyInDB
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 resume
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
12
 pending
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 invoice
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
6
 latestInvoice
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 syncTaxPercentage
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 syncTaxRates
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
20
 getPlanTaxRatesForPayload
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 hasIncompletePayment
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 latestPayment
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
12
 guardAgainstIncomplete
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 guardAgainstMultiplePlans
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 updateStripeSubscription
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 asStripeSubscription
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 plan
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3namespace App\Http\Models;
4
5use App\Jobs\HubspotSyncJob;
6use App\Observers\SubscriptionObserver;
7use Carbon\Carbon;
8use Carbon\CarbonInterface;
9use DateTimeInterface;
10use Illuminate\Database\Eloquent\Attributes\ObservedBy;
11use Illuminate\Database\Eloquent\Builder;
12use Illuminate\Database\Eloquent\Factories\HasFactory;
13use Illuminate\Database\Eloquent\Relations\BelongsTo;
14use Illuminate\Database\Eloquent\Relations\HasMany;
15use Illuminate\Notifications\Notifiable;
16use Illuminate\Support\Collection;
17use InvalidArgumentException;
18use Laravel\Cashier\Cashier;
19use Laravel\Cashier\Concerns\InteractsWithPaymentBehavior;
20use Laravel\Cashier\Concerns\Prorates;
21use Laravel\Cashier\Exceptions\IncompletePayment;
22use Laravel\Cashier\Exceptions\SubscriptionUpdateFailure;
23use Laravel\Cashier\Invoice;
24use Laravel\Cashier\Payment;
25use Laravel\Cashier\SubscriptionItem;
26use LogicException;
27use Stripe\Subscription as StripeSubscription;
28use Stripe\UsageRecord;
29
30/**
31 * This model should align with Laravel\Cashier\Subscription
32 * as much as possible especially in an upgrade
33 */
34#[ObservedBy([SubscriptionObserver::class])]
35class Subscription extends Moloquent
36{
37    use HasFactory, Notifiable, Prorates;
38    use InteractsWithPaymentBehavior;
39
40    /**
41     * The attributes that are not mass assignable.
42     *
43     * @var array
44     */
45    protected $guarded = [];
46
47    /**
48     * The relations to eager load on every query.
49     *
50     * @var array
51     */
52    protected $with = ['items'];
53
54    /**
55     * The attributes that should be cast to native types.
56     *
57     * @var array
58     */
59    protected $casts = [
60        'ends_at' => 'datetime',
61        'trial_ends_at' => 'datetime',
62        'quantity' => 'integer',
63        'current_period_end' => 'datetime',
64        'current_period_start' => 'datetime',
65    ];
66
67    /**
68     * The attributes that should be mutated to dates.
69     *
70     * @var array
71     */
72    protected $dates = [
73        'created_at',
74        'ends_at',
75        'trial_ends_at',
76        'updated_at',
77        'starts_at',
78        'current_period_end',
79        'current_period_start',
80    ];
81
82    /**
83     * The date on which the billing cycle should be anchored.
84     *
85     * @var string|null
86     */
87    protected $billingCycleAnchor = null;
88
89    /**
90     * Recent update to laravel cashier is forcing the stripe_plan field into stripe_price.
91     * Here is an attempt to ensure there's still a stripe_plan field.
92     */
93    public static function boot(): void
94    {
95        parent::boot();
96
97        self::creating(function ($model) {
98            $model->stripe_plan = $model->stripe_price ?? $model->stripe_plan;
99        });
100    }
101
102    /**
103     * Get the user that owns the subscription.
104     */
105    public function user(): BelongsTo
106    {
107        return $this->owner();
108    }
109
110    /**
111     * Get the model related to the subscription.
112     */
113    public function owner(): BelongsTo
114    {
115        $model = Cashier::$customerModel;
116
117        return $this->belongsTo($model, (new $model)->getForeignKey());
118    }
119
120    /**
121     * Get the subscription items related to the subscription.
122     */
123    public function items(): HasMany
124    {
125        return $this->hasMany(Cashier::$subscriptionItemModel);
126    }
127
128    /**
129     * Determine if the subscription has multiple plans.
130     */
131    public function hasMultiplePlans(): bool
132    {
133        return is_null($this->stripe_plan);
134    }
135
136    /**
137     * Determine if the subscription has a single plan.
138     */
139    public function hasSinglePlan(): bool
140    {
141        return ! $this->hasMultiplePlans();
142    }
143
144    /**
145     * Determine if the subscription has a specific plan.
146     */
147    public function hasPlan(string $plan): bool
148    {
149        if ($this->hasMultiplePlans()) {
150            return $this->items->contains(function (SubscriptionItem $item) use ($plan) {
151                return $item->stripe_plan === $plan;
152            });
153        }
154
155        return $this->stripe_plan === $plan;
156    }
157
158    /**
159     * Get the subscription item for the given plan.
160     *
161     *
162     * @throws \Illuminate\Database\Eloquent\ModelNotFoundException
163     */
164    public function findItemOrFail(string $plan): SubscriptionItem
165    {
166        return $this->items()->where('stripe_plan', $plan)->firstOrFail();
167    }
168
169    /**
170     * Determine if the subscription is active, on trial, or within its grace period.
171     */
172    public function valid(): bool
173    {
174        return $this->active() || $this->onTrial() || $this->onGracePeriod();
175    }
176
177    /**
178     * Determine if the subscription is incomplete.
179     */
180    public function incomplete(): bool
181    {
182        return $this->stripe_status === StripeSubscription::STATUS_INCOMPLETE;
183    }
184
185    /**
186     * Filter query by incomplete.
187     */
188    public function scopeIncomplete(Builder $query): void
189    {
190        $query->where('stripe_status', StripeSubscription::STATUS_INCOMPLETE);
191    }
192
193    /**
194     * Determine if the subscription is past due.
195     */
196    public function pastDue(): bool
197    {
198        return $this->stripe_status === StripeSubscription::STATUS_PAST_DUE;
199    }
200
201    /**
202     * Filter query by past due.
203     */
204    public function scopePastDue(Builder $query): void
205    {
206        $query->where('stripe_status', StripeSubscription::STATUS_PAST_DUE);
207    }
208
209    /**
210     * Determine if the subscription is active.
211     */
212    public function active(): bool
213    {
214        return (is_null($this->ends_at) || $this->onGracePeriod()) &&
215            $this->stripe_status !== StripeSubscription::STATUS_INCOMPLETE &&
216            $this->stripe_status !== StripeSubscription::STATUS_INCOMPLETE_EXPIRED &&
217            (! Cashier::$deactivatePastDue || $this->stripe_status !== StripeSubscription::STATUS_PAST_DUE) &&
218            $this->stripe_status !== StripeSubscription::STATUS_UNPAID;
219    }
220
221    public function canceled()
222    {
223        return ! is_null($this->ends_at);
224    }
225
226    /**
227     * Filter query by active.
228     */
229    public function scopeActive(Builder $query): void
230    {
231        $query->where(function ($query) {
232            $query->whereNull('ends_at')
233                ->orWhere(function ($query) {
234                    $query->onGracePeriod();
235                });
236        })->where('stripe_status', '!=', StripeSubscription::STATUS_INCOMPLETE)
237            ->where('stripe_status', '!=', StripeSubscription::STATUS_INCOMPLETE_EXPIRED)
238            ->where('stripe_status', '!=', StripeSubscription::STATUS_UNPAID);
239
240        if (Cashier::$deactivatePastDue) {
241            $query->where('stripe_status', '!=', StripeSubscription::STATUS_PAST_DUE);
242        }
243    }
244
245    /**
246     * Sync the Stripe status of the subscription.
247     */
248    public function syncStripeStatus(): void
249    {
250        $subscription = $this->asStripeSubscription();
251
252        $this->stripe_status = $subscription->status;
253
254        $this->save();
255    }
256
257    /**
258     * Determine if the subscription is recurring and not on trial.
259     */
260    public function recurring(): bool
261    {
262        return ! $this->onTrial() && ! $this->cancelled();
263    }
264
265    /**
266     * Filter query by recurring.
267     */
268    public function scopeRecurring(Builder $query): void
269    {
270        $query->notOnTrial()->notCancelled();
271    }
272
273    /**
274     * Determine if the subscription is no longer active.
275     */
276    public function cancelled(): bool
277    {
278        return ! is_null($this->ends_at);
279    }
280
281    /**
282     * Filter query by cancelled.
283     */
284    public function scopeCancelled(Builder $query): void
285    {
286        $query->whereNotNull('ends_at');
287    }
288
289    /**
290     * Filter query by not cancelled.
291     */
292    public function scopeNotCancelled(Builder $query): void
293    {
294        $query->whereNull('ends_at');
295    }
296
297    /**
298     * Determine if the subscription has ended and the grace period has expired.
299     */
300    public function ended(): bool
301    {
302        return $this->cancelled() && ! $this->onGracePeriod();
303    }
304
305    /**
306     * Filter query by ended.
307     */
308    public function scopeEnded(Builder $query): void
309    {
310        $query->cancelled()->notOnGracePeriod();
311    }
312
313    /**
314     * Determine if the subscription is within its trial period.
315     */
316    public function onTrial(): bool
317    {
318        return $this->trial_ends_at && $this->trial_ends_at->isFuture();
319    }
320
321    /**
322     * Filter query by on trial.
323     */
324    public function scopeOnTrial(Builder $query): void
325    {
326        $query->whereNotNull('trial_ends_at')->where('trial_ends_at', '>', Carbon::now());
327    }
328
329    /**
330     * Filter query by not on trial.
331     */
332    public function scopeNotOnTrial(Builder $query): void
333    {
334        $query->whereNull('trial_ends_at')->orWhere('trial_ends_at', '<=', Carbon::now());
335    }
336
337    /**
338     * Determine if the subscription is within its grace period after cancellation.
339     */
340    public function onGracePeriod(): bool
341    {
342        return $this->ends_at && $this->ends_at->isFuture();
343    }
344
345    public function proTeamEnded(): bool
346    {
347        return $this->ends_at && $this->ends_at->isPast();
348    }
349
350    /**
351     * Filter query by on grace period.
352     */
353    public function scopeOnGracePeriod(Builder $query): void
354    {
355        $query->whereNotNull('ends_at')->where('ends_at', '>', Carbon::now());
356    }
357
358    /**
359     * Filter query by not on grace period.
360     */
361    public function scopeNotOnGracePeriod(Builder $query): void
362    {
363        $query->whereNull('ends_at')->orWhere('ends_at', '<=', Carbon::now());
364    }
365
366    /**
367     * Increment the quantity of the subscription.
368     *
369     *
370     * @throws \Laravel\Cashier\Exceptions\SubscriptionUpdateFailure
371     */
372    public function incrementQuantity(int $count = 1, ?string $plan = null): static
373    {
374        $this->guardAgainstIncomplete();
375
376        if ($plan) {
377            $this->findItemOrFail($plan)->setProrationBehavior($this->prorationBehavior)->incrementQuantity($count);
378
379            return $this->refresh();
380        }
381
382        $this->guardAgainstMultiplePlans();
383
384        return $this->updateQuantity($this->quantity + $count, $plan);
385    }
386
387    /**
388     *  Increment the quantity of the subscription, and invoice immediately.
389     *
390     *
391     * @throws \Laravel\Cashier\Exceptions\IncompletePayment
392     * @throws \Laravel\Cashier\Exceptions\SubscriptionUpdateFailure
393     */
394    public function incrementAndInvoice(int $count = 1, ?string $plan = null): static
395    {
396        $this->guardAgainstIncomplete();
397
398        $this->alwaysInvoice();
399
400        return $this->incrementQuantity($count, $plan);
401    }
402
403    /**
404     * Decrement the quantity of the subscription.
405     *
406     *
407     * @throws \Laravel\Cashier\Exceptions\SubscriptionUpdateFailure
408     */
409    public function decrementQuantity(int $count = 1, ?string $plan = null): static
410    {
411        $this->guardAgainstIncomplete();
412
413        if ($plan) {
414            $this->findItemOrFail($plan)->setProrationBehavior($this->prorationBehavior)->decrementQuantity($count);
415
416            return $this->refresh();
417        }
418
419        $this->guardAgainstMultiplePlans();
420
421        return $this->updateQuantity(max(1, $this->quantity - $count), $plan);
422    }
423
424    /**
425     * Update the quantity of the subscription.
426     *
427     *
428     * @throws \Laravel\Cashier\Exceptions\SubscriptionUpdateFailure
429     */
430    public function updateQuantity(int $quantity, ?string $plan = null): static
431    {
432        $this->guardAgainstIncomplete();
433
434        if ($plan) {
435            $this->findItemOrFail($plan)->setProrationBehavior($this->prorationBehavior)->updateQuantity($quantity);
436
437            return $this->refresh();
438        }
439
440        $this->guardAgainstMultiplePlans();
441
442        $stripeSubscription = $this->asStripeSubscription();
443
444        $stripeSubscription->quantity = $quantity;
445        $stripeSubscription->payment_behavior = $this->paymentBehavior();
446        $stripeSubscription->proration_behavior = $this->prorateBehavior();
447
448        $stripeSubscription->save();
449
450        $this->quantity = $quantity;
451
452        $this->save();
453
454        return $this;
455    }
456
457    /**
458     * Report usage for a metered product.
459     *
460     * @param  \DateTimeInterface|int|null  $timestamp
461     */
462    public function reportUsage(int $quantity = 1, $timestamp = null, ?string $plan = null): UsageRecord
463    {
464        if (! $plan) {
465            $this->guardAgainstMultiplePlans();
466        }
467
468        return $this->findItemOrFail($plan ?? $this->stripe_plan)->reportUsage($quantity, $timestamp);
469    }
470
471    /**
472     * Report usage for specific price of a metered product.
473     *
474     * @param  \DateTimeInterface|int|null  $timestamp
475     */
476    public function reportUsageFor(string $plan, int $quantity = 1, $timestamp = null): UsageRecord
477    {
478        return $this->reportUsage($quantity, $timestamp, $plan);
479    }
480
481    /**
482     * Get the usage records for a metered product.
483     */
484    public function usageRecords(array $options = [], ?string $plan = null): Collection
485    {
486        if (! $plan) {
487            $this->guardAgainstMultiplePlans();
488        }
489
490        return $this->findItemOrFail($plan ?? $this->stripe_plan)->usageRecords($options);
491    }
492
493    /**
494     * Get the usage records for a specific price of a metered product.
495     */
496    public function usageRecordsFor(string $plan, array $options = []): Collection
497    {
498        return $this->usageRecords($options, $plan);
499    }
500
501    /**
502     * Change the billing cycle anchor on a plan change.
503     *
504     * @param  \DateTimeInterface|int|string  $date
505     */
506    public function anchorBillingCycleOn($date = 'now'): static
507    {
508        if ($date instanceof DateTimeInterface) {
509            $date = $date->getTimestamp();
510        }
511
512        $this->billingCycleAnchor = $date;
513
514        return $this;
515    }
516
517    /**
518     * Force the trial to end immediately.
519     *
520     * This method must be combined with swap, resume, etc.
521     */
522    public function skipTrial(): static
523    {
524        $this->trial_ends_at = null;
525
526        return $this;
527    }
528
529    /**
530     * Force the subscription's trial to end immediately.
531     */
532    public function endTrial(): static
533    {
534        if (is_null($this->trial_ends_at)) {
535            return $this;
536        }
537
538        $this->updateStripeSubscription([
539            'trial_end' => 'now',
540            'proration_behavior' => $this->prorateBehavior(),
541        ]);
542
543        $this->trial_ends_at = null;
544
545        $this->save();
546
547        return $this;
548    }
549
550    /**
551     * Extend an existing subscription's trial period.
552     */
553    public function extendTrial(CarbonInterface $date): static
554    {
555        if (! $date->isFuture()) {
556            throw new InvalidArgumentException("Extending a subscription's trial requires a date in the future.");
557        }
558
559        $this->updateStripeSubscription([
560            'trial_end' => $date->getTimestamp(),
561            'proration_behavior' => $this->prorateBehavior(),
562        ]);
563
564        $this->trial_ends_at = $date;
565
566        $this->save();
567
568        return $this;
569    }
570
571    /**
572     * Swap the subscription to new Stripe plans.
573     *
574     * @param  string|array  $plans
575     *
576     * @throws \Laravel\Cashier\Exceptions\SubscriptionUpdateFailure
577     */
578    public function swap($plans, array $options = []): static
579    {
580        if (empty($plans = (array) $plans)) {
581            throw new InvalidArgumentException('Please provide at least one plan when swapping.');
582        }
583
584        $this->guardAgainstIncomplete();
585
586        $items = $this->mergeItemsThatShouldBeDeletedDuringSwap(
587            $this->parseSwapPlans($plans)
588        );
589
590        // $stripeSubscription = StripeSubscription::update(
591        //     $this->stripe_id,
592        //     $this->getSwapOptions($items, $options),
593        //     $this->owner->stripe()
594        // );
595        $stripeSubscription = $this->owner->stripe()->subscriptions->update(
596            $this->stripe_id,
597            $this->getSwapOptions($items, $options)
598        );
599
600        /** @var \Stripe\SubscriptionItem $firstItem */
601        $firstItem = $stripeSubscription->items->first();
602        $isSinglePlan = $stripeSubscription->items->count() === 1;
603
604        $this->fill([
605            'stripe_status' => $stripeSubscription->status,
606            'stripe_plan' => $isSinglePlan ? $firstItem->plan->id : null,
607            'quantity' => $isSinglePlan ? $firstItem->quantity : null,
608            'ends_at' => null,
609        ])->save();
610
611        foreach ($stripeSubscription->items as $item) {
612            $this->items()->updateOrCreate([
613                'stripe_id' => $item->id,
614            ], [
615                'stripe_plan' => $item->plan->id,
616                'quantity' => $item->quantity,
617            ]);
618        }
619
620        // Delete items that aren't attached to the subscription anymore...
621        $this->items()->whereNotIn('stripe_plan', $items->pluck('plan')->filter())->delete();
622
623        $this->unsetRelation('items');
624
625        if ($this->hasIncompletePayment()) {
626            (new Payment(
627                $stripeSubscription->latest_invoice->payment_intent
628            ))->validate();
629        }
630
631        // Dispatch one HubSpot sync so the in-place plan-change path always
632        // triggers a seed-then-merge sync regardless of Stripe webhook timing.
633        HubspotSyncJob::dispatch($this->owner->email, $this->owner->id);
634
635        return $this;
636    }
637
638    /**
639     * Swap the subscription to new Stripe plans, and invoice immediately.
640     *
641     * @param  string|array  $plans
642     *
643     * @throws \Laravel\Cashier\Exceptions\IncompletePayment
644     * @throws \Laravel\Cashier\Exceptions\SubscriptionUpdateFailure
645     */
646    public function swapAndInvoice($plans, array $options = []): static
647    {
648        $this->alwaysInvoice();
649
650        return $this->swap($plans, $options);
651    }
652
653    /**
654     * Parse the given plans for a swap operation.
655     */
656    protected function parseSwapPlans(array $plans): Collection
657    {
658        $isSinglePlanSwap = $this->hasSinglePlan() && count($plans) === 1;
659
660        return collect($plans)->mapWithKeys(function ($options, $plan) use ($isSinglePlanSwap) {
661            $plan = is_string($options) ? $options : $plan;
662
663            $options = is_string($options) ? [] : $options;
664
665            return [$plan => array_merge([
666                'plan' => $plan,
667                'quantity' => $isSinglePlanSwap ? $this->quantity : null,
668                'tax_rates' => $this->getPlanTaxRatesForPayload($plan),
669            ], $options)];
670        });
671    }
672
673    /**
674     * Merge the items that should be deleted during swap into the given items collection.
675     */
676    protected function mergeItemsThatShouldBeDeletedDuringSwap(Collection $items): Collection
677    {
678        /** @var \Stripe\SubscriptionItem $stripeSubscriptionItem */
679        foreach ($this->asStripeSubscription()->items->data as $stripeSubscriptionItem) {
680            $plan = $stripeSubscriptionItem->plan;
681
682            if (! $item = $items->get($plan->id, [])) {
683                $item['deleted'] = true;
684
685                if ($plan->usage_type == 'metered') {
686                    $item['clear_usage'] = true;
687                }
688            }
689
690            $items->put($plan->id, $item + ['id' => $stripeSubscriptionItem->id]);
691        }
692
693        return $items;
694    }
695
696    /**
697     * Get the options array for a swap operation.
698     */
699    protected function getSwapOptions(Collection $items, array $options): array
700    {
701        $payload = [
702            'items' => $items->values()->all(),
703            'payment_behavior' => $this->paymentBehavior(),
704            'proration_behavior' => $this->prorateBehavior(),
705            'expand' => ['latest_invoice.payment_intent'],
706        ];
707
708        if ($payload['payment_behavior'] !== StripeSubscription::PAYMENT_BEHAVIOR_PENDING_IF_INCOMPLETE) {
709            $payload['cancel_at_period_end'] = false;
710        }
711
712        $payload = array_merge($payload, $options);
713
714        if (! is_null($this->billingCycleAnchor)) {
715            $payload['billing_cycle_anchor'] = $this->billingCycleAnchor;
716        }
717
718        $payload['trial_end'] = $this->onTrial()
719            ? $this->trial_ends_at->getTimestamp()
720            : 'now';
721
722        return $payload;
723    }
724
725    /**
726     * Add a new Stripe plan to the subscription.
727     *
728     *
729     * @throws \Laravel\Cashier\Exceptions\SubscriptionUpdateFailure
730     */
731    public function addPlan(string $plan, ?int $quantity = 1, array $options = []): static
732    {
733        $this->guardAgainstIncomplete();
734
735        if ($this->items->contains('stripe_plan', $plan)) {
736            throw SubscriptionUpdateFailure::duplicatePlan($this, $plan);
737        }
738
739        $subscription = $this->asStripeSubscription();
740
741        $item = $subscription->items->create(array_merge([
742            'plan' => $plan,
743            'quantity' => $quantity,
744            'tax_rates' => $this->getPlanTaxRatesForPayload($plan),
745            'payment_behavior' => $this->paymentBehavior(),
746            'proration_behavior' => $this->prorateBehavior(),
747        ], $options));
748
749        $this->items()->create([
750            'stripe_id' => $item->id,
751            'stripe_plan' => $plan,
752            'quantity' => $quantity,
753        ]);
754
755        $this->unsetRelation('items');
756
757        if ($this->hasSinglePlan()) {
758            $this->fill([
759                'stripe_plan' => null,
760                'quantity' => null,
761            ])->save();
762        }
763
764        return $this;
765    }
766
767    /**
768     * Add a new Stripe plan to the subscription, and invoice immediately.
769     *
770     *
771     * @throws \Laravel\Cashier\Exceptions\IncompletePayment
772     * @throws \Laravel\Cashier\Exceptions\SubscriptionUpdateFailure
773     */
774    public function addPlanAndInvoice(string $plan, int $quantity = 1, array $options = []): static
775    {
776        $this->alwaysInvoice();
777
778        return $this->addPlan($plan, $quantity, $options);
779    }
780
781    /**
782     * Remove a Stripe plan from the subscription.
783     *
784     *
785     * @throws \Laravel\Cashier\Exceptions\SubscriptionUpdateFailure
786     */
787    public function removePlan(string $plan): static
788    {
789        if ($this->hasSinglePlan()) {
790            throw SubscriptionUpdateFailure::cannotDeleteLastPlan($this);
791        }
792
793        $stripeItem = $this->findItemOrFail($plan)->asStripeSubscriptionItem();
794
795        $stripeItem->delete(array_filter([
796            'clear_usage' => $stripeItem->plan->usage_type === 'metered' ? true : null,
797            'proration_behavior' => $this->prorateBehavior(),
798        ]));
799
800        $this->items()->where('stripe_plan', $plan)->delete();
801
802        $this->unsetRelation('items');
803
804        if ($this->items()->count() < 2) {
805            $item = $this->items()->first();
806
807            $this->fill([
808                'stripe_plan' => $item->stripe_plan,
809                'quantity' => $item->quantity,
810            ])->save();
811        }
812
813        return $this;
814    }
815
816    /**
817     * Cancel the subscription at the end of the billing period.
818     */
819    public function cancel(): static
820    {
821        $subscription = $this->asStripeSubscription();
822
823        $subscription->cancel_at_period_end = true;
824
825        $subscription = $subscription->save();
826
827        $this->stripe_status = $subscription->status;
828
829        // If the user was on trial, we will set the grace period to end when the trial
830        // would have ended. Otherwise, we'll retrieve the end of the billing period
831        // period and make that the end of the grace period for this current user.
832        if ($this->onTrial()) {
833            $this->ends_at = $this->trial_ends_at;
834        } else {
835            $this->ends_at = Carbon::createFromTimestamp(
836                $subscription->current_period_end
837            );
838        }
839
840        $this->save();
841
842        return $this;
843    }
844
845    /**
846     * Cancel the subscription at a specific moment in time.
847     *
848     * @param  \DateTimeInterface|int  $endsAt
849     */
850    public function cancelAt($endsAt): static
851    {
852        if ($endsAt instanceof DateTimeInterface) {
853            $endsAt = $endsAt->getTimestamp();
854        }
855
856        $subscription = $this->asStripeSubscription();
857
858        $subscription->proration_behavior = $this->prorateBehavior();
859
860        $subscription->cancel_at = $endsAt;
861
862        $subscription = $subscription->save();
863
864        $this->stripe_status = $subscription->status;
865
866        $this->ends_at = Carbon::createFromTimestamp($subscription->cancel_at);
867
868        $this->save();
869
870        return $this;
871    }
872
873    /**
874     * Cancel the subscription immediately without invoicing.
875     */
876    public function cancelNow(): static
877    {
878        $this->asStripeSubscription()->cancel([
879            'prorate' => $this->prorateBehavior() === 'create_prorations',
880        ]);
881
882        $this->markAsCancelled();
883
884        return $this;
885    }
886
887    /**
888     * Cancel the subscription immediately and invoice.
889     */
890    public function cancelNowAndInvoice(): static
891    {
892        $this->asStripeSubscription()->cancel([
893            'invoice_now' => true,
894            'prorate' => $this->prorateBehavior() === 'create_prorations',
895        ]);
896
897        $this->markAsCancelled();
898
899        return $this;
900    }
901
902    /**
903     * Mark the subscription as cancelled.
904     *
905     *
906     * @internal
907     */
908    public function markAsCancelled(): void
909    {
910        $this->fill([
911            'stripe_status' => StripeSubscription::STATUS_CANCELED,
912            'ends_at' => Carbon::now(),
913        ])->save();
914    }
915
916    public function markAsCancelledOnlyInDB(): void
917    {
918        $this->fill([
919            'stripe_status' => StripeSubscription::STATUS_CANCELED,
920            'ends_at' => Carbon::now(),
921        ])->save();
922    }
923
924    /**
925     * Resume the cancelled subscription.
926     *
927     *
928     * @throws \LogicException
929     */
930    public function resume(): static
931    {
932        if (! $this->onGracePeriod()) {
933            throw new LogicException('Unable to resume subscription that is not within grace period.');
934        }
935
936        $subscription = $this->asStripeSubscription();
937
938        $subscription->cancel_at_period_end = false;
939
940        if ($this->onTrial()) {
941            $subscription->trial_end = $this->trial_ends_at->getTimestamp();
942        } else {
943            $subscription->trial_end = 'now';
944        }
945
946        $subscription = $subscription->save();
947
948        // Finally, we will remove the ending timestamp from the user's record in the
949        // local database to indicate that the subscription is active again and is
950        // no longer "cancelled". Then we will save this record in the database.
951        $this->fill([
952            'stripe_status' => $subscription->status,
953            'ends_at' => null,
954        ])->save();
955
956        return $this;
957    }
958
959    /**
960     * Determine if the subscription has pending updates.
961     */
962    public function pending(): bool
963    {
964        return $this->asStripeSubscription()->pending_update !== null;
965    }
966
967    /**
968     * Invoice the subscription outside of the regular billing cycle.
969     *
970     * @return \Laravel\Cashier\Invoice|bool
971     *
972     * @throws \Laravel\Cashier\Exceptions\IncompletePayment
973     */
974    public function invoice(array $options = [])
975    {
976        try {
977            return $this->user->invoice(array_merge($options, ['subscription' => $this->stripe_id]));
978        } catch (IncompletePayment $exception) {
979            // Set the new Stripe subscription status immediately when payment fails...
980            $this->fill([
981                'stripe_status' => $exception->payment->invoice->subscription->status,
982            ])->save();
983
984            throw $exception;
985        }
986    }
987
988    /**
989     * Get the latest invoice for the subscription.
990     */
991    public function latestInvoice(): ?Invoice
992    {
993        $stripeSubscription = $this->asStripeSubscription(['latest_invoice']);
994
995        if ($stripeSubscription->latest_invoice) {
996            return new Invoice($this->owner, $stripeSubscription->latest_invoice);
997        }
998    }
999
1000    /**
1001     * Sync the tax percentage of the user to the subscription.
1002     *
1003     *
1004     * @deprecated Please migrate to the new Tax Rates API.
1005     */
1006    public function syncTaxPercentage(): void
1007    {
1008        $subscription = $this->asStripeSubscription();
1009
1010        $subscription->tax_percent = $this->user->taxPercentage();
1011
1012        $subscription->save();
1013    }
1014
1015    /**
1016     * Sync the tax rates of the user to the subscription.
1017     */
1018    public function syncTaxRates(): void
1019    {
1020        $stripeSubscription = $this->asStripeSubscription();
1021
1022        $stripeSubscription->default_tax_rates = $this->user->taxRates() ?: null;
1023
1024        $stripeSubscription->proration_behavior = $this->prorateBehavior();
1025
1026        $stripeSubscription->save();
1027
1028        foreach ($this->items as $item) {
1029            $stripeSubscriptionItem = $item->asStripeSubscriptionItem();
1030
1031            $stripeSubscriptionItem->tax_rates = $this->getPlanTaxRatesForPayload($item->stripe_plan) ?: null;
1032
1033            $stripeSubscriptionItem->proration_behavior = $this->prorateBehavior();
1034
1035            $stripeSubscriptionItem->save();
1036        }
1037    }
1038
1039    /**
1040     * Get the plan tax rates for the Stripe payload.
1041     */
1042    public function getPlanTaxRatesForPayload(string $plan): ?array
1043    {
1044        if ($taxRates = $this->owner->planTaxRates()) {
1045            return $taxRates[$plan] ?? null;
1046        }
1047
1048        return null;
1049    }
1050
1051    /**
1052     * Determine if the subscription has an incomplete payment.
1053     */
1054    public function hasIncompletePayment(): bool
1055    {
1056        return $this->pastDue() || $this->incomplete();
1057    }
1058
1059    /**
1060     * Get the latest payment for a Subscription.
1061     */
1062    public function latestPayment(): ?Payment
1063    {
1064        $subscription = $this->asStripeSubscription(['latest_invoice.payment_intent']);
1065
1066        if ($invoice = $subscription->latest_invoice) {
1067            return $invoice->payment_intent
1068                ? new Payment($invoice->payment_intent)
1069                : null;
1070        }
1071    }
1072
1073    /**
1074     * Make sure a subscription is not incomplete when performing changes.
1075     *
1076     *
1077     * @throws \Laravel\Cashier\Exceptions\SubscriptionUpdateFailure
1078     */
1079    public function guardAgainstIncomplete(): void
1080    {
1081        if ($this->incomplete()) {
1082            throw SubscriptionUpdateFailure::incompleteSubscription($this);
1083        }
1084    }
1085
1086    /**
1087     * Make sure a plan argument is provided when the subscription is a multi plan subscription.
1088     *
1089     *
1090     * @throws \InvalidArgumentException
1091     */
1092    public function guardAgainstMultiplePlans(): void
1093    {
1094        if ($this->hasMultiplePlans()) {
1095            throw new InvalidArgumentException(
1096                'This method requires a plan argument since the subscription has multiple plans.'
1097            );
1098        }
1099    }
1100
1101    /**
1102     * Update the underlying Stripe subscription information for the model.
1103     */
1104    public function updateStripeSubscription(array $options = []): StripeSubscription
1105    {
1106        return $this->owner->stripe()->subscriptions->update(
1107            $this->stripe_id,
1108            $options
1109        );
1110        // return StripeSubscription::update(
1111        //     $this->stripe_id,
1112        //     $options,
1113        //     $this->owner->stripe()
1114        // );
1115    }
1116
1117    /**
1118     * Get the subscription as a Stripe subscription object.
1119     */
1120    public function asStripeSubscription(array $expand = []): StripeSubscription
1121    {
1122        return $this->owner->stripe()->subscriptions->retrieve(
1123            $this->stripe_id,
1124            ['expand' => $expand]
1125        );
1126        // return StripeSubscription::retrieve(
1127        //     ['id' => $this->stripe_id, 'expand' => $expand],
1128        //     $this->owner->stripe()
1129        // );
1130    }
1131
1132    public function plan()
1133    {
1134        return $this->hasOne(Plans::class, 'stripe_id', 'stripe_plan');
1135    }
1136}