Skip to content

Commit

Permalink
Handle card failure in plan swap
Browse files Browse the repository at this point in the history
Previously, when a plan swap was attempted and payment failed, the exception was cascaded to the end user and the update to the subscription in the app was not performed. However, the update to the subscription in Stripe itself was performed and the two states would be out of sync unless you implemented webhooks.

We've decided to catch the card failure exception and allow the plan swap to continue regardless of the failed payment. This leaves the subscription in a "past_due" state. This is because payment failure will be handled by Stripe and Stripe may attempt to retry the payment later on. When payment finally fails on its last attempt Stripe will send out a webhook to update the subscription in the way you specified in its settings: https://stripe.com/docs/billing/lifecycle#settings
  • Loading branch information
driesvints committed Apr 12, 2019
1 parent 4fe95c0 commit 2edac28
Show file tree
Hide file tree
Showing 3 changed files with 57 additions and 3 deletions.
8 changes: 8 additions & 0 deletions UPGRADE.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,14 @@ To accommodate for this new behavior from now on Cashier will cancel that subscr

If you were relying on catching the `\Stripe\Error\Card` exception before you should now rely on catching the `Laravel\Cashier\Exceptions\SubscriptionCreationFailed` exception instead.

### Card failure upon plan swapping

Previously, when a plan swap was attempted and payment failed, the exception was cascaded to the end user and the update to the subscription in the app was not performed. However, the update to the subscription in Stripe itself was performed and the two states would be out of sync unless you implemented webhooks.

We've decided to catch the card failure exception and allow the plan swap to continue regardless of the failed payment. This leaves the subscription in a "past_due" state. This is because payment failure will be handled by Stripe and Stripe may attempt to retry the payment later on. When payment finally fails on its last attempt Stripe will send out a webhook to update the subscription in the way you specified in its settings: https://stripe.com/docs/billing/lifecycle#settings

The change you should accommodate for is to implement Stripe's webhooks to let Cashier update the subscription automatically. [See our instructions for setting up Stripe webhooks with Cashier.](https://laravel.com/docs/master/billing#handling-stripe-webhooks)

## Upgrading To 9.0 From 8.0

### PHP & Laravel Version Requirements
Expand Down
9 changes: 8 additions & 1 deletion src/Subscription.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
use Carbon\Carbon;
use LogicException;
use DateTimeInterface;
use Stripe\Error\Card as StripeCard;
use Illuminate\Database\Eloquent\Model;

class Subscription extends Model
Expand Down Expand Up @@ -379,7 +380,13 @@ public function swap($plan)

$subscription->save();

$this->user->invoice(['subscription' => $subscription->id]);
try {
$this->user->invoice(['subscription' => $subscription->id]);
} catch (StripeCard $exception) {
// When the payment for the plan swap fails, we continue to let the user swap to the
// new plan. This is because Stripe may attempt to retry the payment later on. If
// all attempts to collect payment fail, webhooks will handle any update to it.
}

$this->fill([
'stripe_plan' => $plan,
Expand Down
43 changes: 41 additions & 2 deletions tests/Integration/CashierTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,11 @@ class CashierTest extends TestCase
*/
protected static $otherPlanId;

/**
* @var string
*/
protected static $premiumPlanId;

/**
* @var string
*/
Expand All @@ -63,6 +68,7 @@ protected static function setUpStripeTestData()
static::$productId = static::$stripePrefix.'product-1'.Str::random(10);
static::$planId = static::$stripePrefix.'monthly-10-'.Str::random(10);
static::$otherPlanId = static::$stripePrefix.'monthly-10-'.Str::random(10);
static::$premiumPlanId = static::$stripePrefix.'monthly-20-premium-'.Str::random(10);
static::$couponId = static::$stripePrefix.'coupon-'.Str::random(10);

Product::create([
Expand All @@ -73,7 +79,7 @@ protected static function setUpStripeTestData()

Plan::create([
'id' => static::$planId,
'nickname' => 'Monthly $10 Test 1',
'nickname' => 'Monthly $10',
'currency' => 'USD',
'interval' => 'month',
'billing_scheme' => 'per_unit',
Expand All @@ -83,14 +89,24 @@ protected static function setUpStripeTestData()

Plan::create([
'id' => static::$otherPlanId,
'nickname' => 'Monthly $10 Test 2',
'nickname' => 'Monthly $10 Other',
'currency' => 'USD',
'interval' => 'month',
'billing_scheme' => 'per_unit',
'amount' => 1000,
'product' => static::$productId,
]);

Plan::create([
'id' => static::$premiumPlanId,
'nickname' => 'Monthly $20 Premium',
'currency' => 'USD',
'interval' => 'month',
'billing_scheme' => 'per_unit',
'amount' => 2000,
'product' => static::$productId,
]);

Coupon::create([
'id' => static::$couponId,
'duration' => 'repeating',
Expand Down Expand Up @@ -147,6 +163,7 @@ public static function tearDownAfterClass()

static::deleteStripeResource(new Plan(static::$planId));
static::deleteStripeResource(new Plan(static::$otherPlanId));
static::deleteStripeResource(new Plan(static::$premiumPlanId));
static::deleteStripeResource(new Product(static::$productId));
static::deleteStripeResource(new Coupon(static::$couponId));
}
Expand Down Expand Up @@ -260,6 +277,28 @@ public function test_creating_subscription_fails_when_card_is_declined()
}
}

/**
* @group Swapping
*/
public function test_plan_swap_succeeds_even_if_payment_fails()
{
$user = User::create([
'email' => 'taylor@laravel.com',
'name' => 'Taylor Otwell',
]);

$subscription = $user->newSubscription('main', static::$planId)->create($this->getTestToken());

// Set a faulty card as the customer's default card.
$user->updateCard($this->getInvalidCardToken());

// Attempt to swap and pay with a faulty card.
$subscription = $subscription->swap(static::$premiumPlanId);

// Assert that the plan was swapped.
$this->assertEquals(static::$premiumPlanId, $subscription->stripe_plan);
}

public function test_creating_subscription_with_coupons()
{
$user = User::create([
Expand Down

0 comments on commit 2edac28

Please sign in to comment.