Skip to content

Commit 2d78c82

Browse files
authored
Merge pull request #707 from laravel/improve-stripe-statuses
[10.0] Improve stripe statuses
2 parents 40db8e8 + ca7b859 commit 2d78c82

9 files changed

+194
-43
lines changed

database/migrations/2019_05_03_000002_create_subscriptions_table.php

+1-1
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,8 @@ public function up()
1717
$table->bigIncrements('id');
1818
$table->unsignedBigInteger('user_id');
1919
$table->string('name');
20-
$table->string('status');
2120
$table->string('stripe_id')->collation('utf8mb4_bin');
21+
$table->string('stripe_status');
2222
$table->string('stripe_plan');
2323
$table->integer('quantity');
2424
$table->timestamp('trial_ends_at')->nullable();

src/Billable.php

+18-1
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,20 @@ public function subscriptions()
197197
return $this->hasMany(Subscription::class, $this->getForeignKey())->orderBy('created_at', 'desc');
198198
}
199199

200+
/**
201+
* Determine if the customer's subscription has an incomplete payment.
202+
*
203+
* @return bool
204+
*/
205+
public function hasIncompletePayment()
206+
{
207+
if ($subscription = $this->subscription()) {
208+
return $subscription->hasIncompletePayment();
209+
}
210+
211+
return false;
212+
}
213+
200214
/**
201215
* Invoice the billable entity outside of regular billing cycle.
202216
*
@@ -218,7 +232,10 @@ public function invoice(array $options = [])
218232
return false;
219233
} catch (StripeCardException $exception) {
220234
$payment = new Payment(
221-
StripePaymentIntent::retrieve($invoice->refresh()->payment_intent, Cashier::stripeOptions())
235+
StripePaymentIntent::retrieve(
236+
['id' => $invoice->refresh()->payment_intent, 'expand' => ['invoice.subscription']],
237+
Cashier::stripeOptions()
238+
)
222239
);
223240

224241
$payment->validate();
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
<?php
2+
3+
namespace Laravel\Cashier\Exceptions;
4+
5+
use Exception;
6+
use Laravel\Cashier\Subscription;
7+
8+
class SubscriptionUpdateFailure extends Exception
9+
{
10+
/**
11+
* Create a new SubscriptionUpdateFailure instance.
12+
*
13+
* @param \Laravel\Cashier\Subscription $subscription
14+
* @param string $plan
15+
* @return self
16+
*/
17+
public static function incompleteSubscription(Subscription $subscription)
18+
{
19+
return new static("The subscription \"{$subscription->stripe_id}\" cannot be updated because its payment is incomplete.");
20+
}
21+
}

src/Http/Controllers/WebhookController.php

+7-5
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,12 @@ protected function handleCustomerSubscriptionUpdated(array $payload)
6161
$user->subscriptions->filter(function (Subscription $subscription) use ($data) {
6262
return $subscription->stripe_id === $data['id'];
6363
})->each(function (Subscription $subscription) use ($data) {
64+
if (isset($data['status']) && $data['status'] === 'incomplete_expired') {
65+
$subscription->delete();
66+
67+
return;
68+
}
69+
6470
// Quantity...
6571
if (isset($data['quantity'])) {
6672
$subscription->quantity = $data['quantity'];
@@ -93,11 +99,7 @@ protected function handleCustomerSubscriptionUpdated(array $payload)
9399

94100
// Status...
95101
if (isset($data['status'])) {
96-
if (in_array($data['status'], ['incomplete', 'incomplete_expired'])) {
97-
$subscription->status = 'incomplete';
98-
} else {
99-
$subscription->status = 'active';
100-
}
102+
$subscription->stripe_status = $data['status'];
101103
}
102104

103105
$subscription->save();

src/Subscription.php

+55-22
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
use Illuminate\Database\Eloquent\Model;
99
use Stripe\Subscription as StripeSubscription;
1010
use Laravel\Cashier\Exceptions\IncompletePayment;
11+
use Laravel\Cashier\Exceptions\SubscriptionUpdateFailure;
1112

1213
class Subscription extends Model
1314
{
@@ -81,7 +82,7 @@ public function valid()
8182
*/
8283
public function incomplete()
8384
{
84-
return $this->status === 'incomplete';
85+
return $this->stripe_status === 'incomplete';
8586
}
8687

8788
/**
@@ -92,17 +93,28 @@ public function incomplete()
9293
*/
9394
public function scopeIncomplete($query)
9495
{
95-
$query->where('status', 'incomplete');
96+
$query->where('stripe_status', 'incomplete');
9697
}
9798

9899
/**
99-
* Mark the subscription as incomplete.
100+
* Determine if the subscription is past due.
100101
*
102+
* @return bool
103+
*/
104+
public function pastDue()
105+
{
106+
return $this->stripe_status === 'past_due';
107+
}
108+
109+
/**
110+
* Filter query by past due.
111+
*
112+
* @param \Illuminate\Database\Eloquent\Builder $query
101113
* @return void
102114
*/
103-
public function markAsIncomplete()
115+
public function scopePastDue($query)
104116
{
105-
$this->fill(['status' => 'incomplete'])->save();
117+
$query->where('stripe_status', 'past_due');
106118
}
107119

108120
/**
@@ -124,22 +136,12 @@ public function active()
124136
public function scopeActive($query)
125137
{
126138
$query->whereNull('ends_at')
127-
->where('status', '!=', 'incomplete')
139+
->where('stripe_status', '!=', 'incomplete')
128140
->orWhere(function ($query) {
129141
$query->onGracePeriod();
130142
});
131143
}
132144

133-
/**
134-
* Mark the subscription as active.
135-
*
136-
* @return void
137-
*/
138-
public function markAsActive()
139-
{
140-
$this->fill(['status' => 'active'])->save();
141-
}
142-
143145
/**
144146
* Determine if the subscription is recurring and not on trial.
145147
*
@@ -325,9 +327,15 @@ public function decrementQuantity($count = 1)
325327
* @param int $quantity
326328
* @param \Stripe\Customer|null $customer
327329
* @return $this
330+
*
331+
* @throws \Laravel\Cashier\Exceptions\SubscriptionUpdateFailure
328332
*/
329333
public function updateQuantity($quantity, $customer = null)
330334
{
335+
if ($this->incomplete()) {
336+
throw SubscriptionUpdateFailure::incompleteSubscription($this);
337+
}
338+
331339
$subscription = $this->asStripeSubscription();
332340

333341
$subscription->quantity = $quantity;
@@ -394,9 +402,14 @@ public function skipTrial()
394402
* @return $this
395403
*
396404
* @throws \Laravel\Cashier\Exceptions\IncompletePayment
405+
* @throws \Laravel\Cashier\Exceptions\SubscriptionUpdateFailure
397406
*/
398407
public function swap($plan, $options = [])
399408
{
409+
if ($this->incomplete()) {
410+
throw SubscriptionUpdateFailure::incompleteSubscription($this);
411+
}
412+
400413
$subscription = $this->asStripeSubscription();
401414

402415
$subscription->plan = $plan;
@@ -429,7 +442,7 @@ public function swap($plan, $options = [])
429442
$subscription->quantity = $this->quantity;
430443
}
431444

432-
$subscription->save();
445+
$subscription = $subscription->save();
433446

434447
$this->fill([
435448
'stripe_plan' => $plan,
@@ -439,7 +452,9 @@ public function swap($plan, $options = [])
439452
try {
440453
$this->user->invoice(['subscription' => $subscription->id]);
441454
} catch (IncompletePayment $exception) {
442-
$this->markAsIncomplete();
455+
$this->fill([
456+
'stripe_status' => $exception->payment->invoice->subscription->status,
457+
])->save();
443458

444459
throw $exception;
445460
}
@@ -458,7 +473,9 @@ public function cancel()
458473

459474
$subscription->cancel_at_period_end = true;
460475

461-
$subscription->save();
476+
$subscription = $subscription->save();
477+
478+
$this->stripe_status = $subscription->status;
462479

463480
// If the user was on trial, we will set the grace period to end when the trial
464481
// would have ended. Otherwise, we'll retrieve the end of the billing period
@@ -499,7 +516,10 @@ public function cancelNow()
499516
*/
500517
public function markAsCancelled()
501518
{
502-
$this->fill(['ends_at' => Carbon::now()])->save();
519+
$this->fill([
520+
'stripe_status' => 'canceled',
521+
'ends_at' => Carbon::now(),
522+
])->save();
503523
}
504524

505525
/**
@@ -529,12 +549,15 @@ public function resume()
529549
$subscription->trial_end = 'now';
530550
}
531551

532-
$subscription->save();
552+
$subscription = $subscription->save();
533553

534554
// Finally, we will remove the ending timestamp from the user's record in the
535555
// local database to indicate that the subscription is active again and is
536556
// no longer "cancelled". Then we will save this record in the database.
537-
$this->fill(['ends_at' => null])->save();
557+
$this->fill([
558+
'stripe_status' => $subscription->status,
559+
'ends_at' => null,
560+
])->save();
538561

539562
return $this;
540563
}
@@ -553,6 +576,16 @@ public function syncTaxPercentage()
553576
$subscription->save();
554577
}
555578

579+
/**
580+
* Determine if the subscription has an incomplete payment.
581+
*
582+
* @return bool
583+
*/
584+
public function hasIncompletePayment()
585+
{
586+
return $this->pastDue() || $this->incomplete();
587+
}
588+
556589
/**
557590
* Get the latest payment for a Subscription.
558591
*

src/SubscriptionBuilder.php

+2-4
Original file line numberDiff line numberDiff line change
@@ -212,17 +212,15 @@ public function create($paymentMethod = null, array $options = [])
212212

213213
$subscription = $this->owner->subscriptions()->create([
214214
'name' => $this->name,
215-
'status' => 'active',
216215
'stripe_id' => $stripeSubscription->id,
216+
'stripe_status' => $stripeSubscription->status,
217217
'stripe_plan' => $this->plan,
218218
'quantity' => $this->quantity,
219219
'trial_ends_at' => $trialEndsAt,
220220
'ends_at' => null,
221221
]);
222222

223-
if ($stripeSubscription->status === 'incomplete') {
224-
$subscription->markAsIncomplete();
225-
223+
if ($subscription->incomplete()) {
226224
(new Payment(
227225
$stripeSubscription->latest_invoice->payment_intent
228226
))->validate();

tests/Integration/SubscriptionsTest.php

+6-6
Original file line numberDiff line numberDiff line change
@@ -257,8 +257,8 @@ public function test_declined_card_during_plan_swap_results_in_an_exception()
257257
// Assert that the plan was swapped anyway.
258258
$this->assertEquals(static::$premiumPlanId, $subscription->refresh()->stripe_plan);
259259

260-
// Assert subscription is incomplete.
261-
$this->assertTrue($subscription->incomplete());
260+
// Assert subscription is past due.
261+
$this->assertTrue($subscription->pastDue());
262262
}
263263
}
264264

@@ -283,8 +283,8 @@ public function test_next_action_needed_during_plan_swap_results_in_an_exception
283283
// Assert that the plan was swapped anyway.
284284
$this->assertEquals(static::$premiumPlanId, $subscription->refresh()->stripe_plan);
285285

286-
// Assert subscription is incomplete.
287-
$this->assertTrue($subscription->incomplete());
286+
// Assert subscription is past due.
287+
$this->assertTrue($subscription->pastDue());
288288
}
289289
}
290290

@@ -481,8 +481,8 @@ public function test_subscription_state_scopes()
481481
// Start with an incomplete subscription.
482482
$subscription = $user->subscriptions()->create([
483483
'name' => 'yearly',
484-
'status' => 'incomplete',
485484
'stripe_id' => 'xxxx',
485+
'stripe_status' => 'incomplete',
486486
'stripe_plan' => 'stripe-yearly',
487487
'quantity' => 1,
488488
'trial_ends_at' => null,
@@ -502,7 +502,7 @@ public function test_subscription_state_scopes()
502502
$this->assertFalse($user->subscriptions()->ended()->exists());
503503

504504
// Activate.
505-
$subscription->update(['status' => 'active']);
505+
$subscription->update(['stripe_status' => 'active']);
506506

507507
$this->assertFalse($user->subscriptions()->incomplete()->exists());
508508
$this->assertTrue($user->subscriptions()->active()->exists());

tests/Integration/WebhooksTest.php

+22
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,28 @@ public function test_subscription_is_marked_as_cancelled_when_deleted_in_stripe(
9797
$this->assertTrue($subscription->fresh()->cancelled(), 'Subscription is still active.');
9898
}
9999

100+
public function test_subscription_is_deleted_when_status_is_incomplete_expired()
101+
{
102+
$user = $this->createCustomer('subscription_is_deleted_when_status_is_incomplete_expired');
103+
$subscription = $user->newSubscription('main', static::$planId)->create('pm_card_visa');
104+
105+
$this->assertCount(1, $user->subscriptions);
106+
107+
$this->postJson('stripe/webhook', [
108+
'id' => 'foo',
109+
'type' => 'customer.subscription.updated',
110+
'data' => [
111+
'object' => [
112+
'id' => $subscription->stripe_id,
113+
'customer' => $user->stripe_id,
114+
'status' => 'incomplete_expired',
115+
],
116+
],
117+
])->assertOk();
118+
119+
$this->assertEmpty($user->refresh()->subscriptions, 'Subscription was not deleted.');
120+
}
121+
100122
public function test_payment_action_required_email_is_sent()
101123
{
102124
config(['cashier.payment_emails' => true]);

0 commit comments

Comments
 (0)