This course is archived!
This tutorial uses an older version of Symfony of the stripe-php SDK. The majority of the concepts are still valid, though there *are* differences. We've done our best to add notes & comments that describe these changes.
Failing Awesomely When Payments Fail
Keep on Learning!
If you liked what you've learned so far, dive in! Subscribe to get access to this tutorial plus video, code and script downloads.
You can even downgrade to the Farmer Brent and get a $99
balance on your account.
In the Stripe dashboard, refresh the Customer. Ah, there's an account balance of
$99.84
.
And if you change back to the New Zealander, now it's free! The amount_due
field
on the upcoming invoice correctly calculates its total by using any available
account balance.
So, this is solid.
Card Failure on Upgrade
But what happens if their card is declined when they try to upgrade? I don't know: let's find out. First, go buy a new, fresh subscription. Great!
Now, update your card to be one that will fail when it's charged: 4000 0000 0000 0341
.
Ok, try to change to the New Zealander. It thinks for awhile and then... an AJAX error! You can see it down in the web debug toolbar.
Open the profiler for that request in a new tab and then click "Exception" to see the error. Ah yes, the classic: "Your card was declined". Clearly, we aren't handling this situation very well.
But actually, the problem is worse than you might think. Refresh the Customer in Stripe. You can see the failed payment... but you can also see that the subscription change was successful! We are now on the New Zealander plan.
The customer also has an unpaid invoice, which represents what they should have been charged. Since this is unpaid, Stripe will try to charge it a few more times. In summary, everything is totally borked.
Failing Gracefully
This whole mess starts in StripeClient
, when we call $this->createInvoice()
,
because this might fail:
// ... lines 1 - 4 | |
use AppBundle\Entity\User; | |
use AppBundle\Subscription\SubscriptionPlan; | |
// ... lines 7 - 8 | |
class StripeClient | |
{ | |
// ... lines 11 - 144 | |
public function changePlan(User $user, SubscriptionPlan $newPlan) | |
{ | |
// ... lines 147 - 151 | |
// immediately invoice them | |
$this->createInvoice($user); | |
// ... lines 154 - 155 | |
} | |
} |
Scroll up to that method:
// ... lines 1 - 4 | |
use AppBundle\Entity\User; | |
// ... lines 6 - 8 | |
class StripeClient | |
{ | |
// ... lines 11 - 53 | |
public function createInvoice(User $user, $payImmediately = true) | |
{ | |
$invoice = \Stripe\Invoice::create(array( | |
"customer" => $user->getStripeCustomerId() | |
)); | |
if ($payImmediately) { | |
// guarantee it charges *right* now | |
$invoice->pay(); | |
} | |
return $invoice; | |
} | |
// ... lines 67 - 156 | |
} |
In addition to calling this to upgrade a subscription plan, we also call this at checkout, even if there is no subscription. The problem is that if payment fails on checkout, this invoice will still exist, and Stripe will try again to charge it. Imagine having your card be declined at checkout, only for the vendor to try to charge it again later, without you ever having finished the checkout process!
Here's our rescue plan: if paying the invoice fails, we need to close it. By doing that, Stripe will not try to pay it again.
To do that, surround the pay()
line with a try-catch for the \Stripe\Error\Card
exception:
// ... lines 1 - 4 | |
use AppBundle\Entity\User; | |
// ... lines 6 - 8 | |
class StripeClient | |
{ | |
// ... lines 11 - 53 | |
public function createInvoice(User $user, $payImmediately = true) | |
{ | |
// ... lines 56 - 59 | |
if ($payImmediately) { | |
// guarantee it charges *right* now | |
try { | |
$invoice->pay(); | |
} catch (\Stripe\Error\Card $e) { | |
// ... lines 65 - 70 | |
} | |
} | |
// ... lines 73 - 74 | |
} | |
// ... lines 76 - 175 | |
} |
Here, add $invoice->close = true
and then $invoice->save()
. Then, re-throw the
exception:
// ... lines 1 - 4 | |
use AppBundle\Entity\User; | |
// ... lines 6 - 8 | |
class StripeClient | |
{ | |
// ... lines 11 - 53 | |
public function createInvoice(User $user, $payImmediately = true) | |
{ | |
// ... lines 56 - 59 | |
if ($payImmediately) { | |
// guarantee it charges *right* now | |
try { | |
$invoice->pay(); | |
} catch (\Stripe\Error\Card $e) { | |
// paying failed, close this invoice so we don't | |
// keep trying to pay it | |
$invoice->closed = true; | |
$invoice->save(); | |
throw $e; | |
} | |
} | |
// ... lines 73 - 74 | |
} | |
// ... lines 76 - 175 | |
} |
Our checkout logic looks for this exception and uses it to notify the user of the problem.
Next, down in the other function, if we fail to create the invoice, we need to not change the customer's plan in Stripe.
Add a new variable called $originalPlanId
set to $stripeSubscription->plan->id
:
// ... lines 1 - 4 | |
use AppBundle\Entity\User; | |
use AppBundle\Subscription\SubscriptionPlan; | |
// ... lines 7 - 8 | |
class StripeClient | |
{ | |
// ... lines 11 - 153 | |
public function changePlan(User $user, SubscriptionPlan $newPlan) | |
{ | |
$stripeSubscription = $this->findSubscription($user->getSubscription()->getStripeSubscriptionId()); | |
$originalPlanId = $stripeSubscription->plan->id; | |
// ... lines 159 - 174 | |
} | |
} |
Then, surround the createInvoice()
call with a try-catch block for the same exception:
\Stripe\Error\Card
:
// ... lines 1 - 4 | |
use AppBundle\Entity\User; | |
use AppBundle\Subscription\SubscriptionPlan; | |
// ... lines 7 - 8 | |
class StripeClient | |
{ | |
// ... lines 11 - 153 | |
public function changePlan(User $user, SubscriptionPlan $newPlan) | |
{ | |
// ... lines 156 - 161 | |
try { | |
// immediately invoice them | |
$this->createInvoice($user); | |
} catch (\Stripe\Error\Card $e) { | |
// ... lines 166 - 171 | |
} | |
// ... lines 173 - 174 | |
} | |
} |
Reverting the Plan without Proration
If this happens, we need to do change the subscription plan back to the original
one: $stripeSubscription->plan = $originalPlanId
. But here's the tricky part: add
$stripeSubscription->prorate = false
:
// ... lines 1 - 4 | |
use AppBundle\Entity\User; | |
use AppBundle\Subscription\SubscriptionPlan; | |
// ... lines 7 - 8 | |
class StripeClient | |
{ | |
// ... lines 11 - 153 | |
public function changePlan(User $user, SubscriptionPlan $newPlan) | |
{ | |
// ... lines 156 - 161 | |
try { | |
// immediately invoice them | |
$this->createInvoice($user); | |
} catch (\Stripe\Error\Card $e) { | |
$stripeSubscription->plan = $originalPlanId; | |
// prevent prorations discounts/charges from changing back | |
$stripeSubscription->prorate = false; | |
// ... lines 169 - 171 | |
} | |
// ... lines 173 - 174 | |
} | |
} |
Why? When we originally change the plan, that creates the two proration invoice items. If the invoice fails to pay, then the invoice containing those invoice items is closed. And that means, effectively, those invoice items have been deleted, which is good! In fact, it's exactly what we want.
But when we change from the new plan back to the old plan, we don't want two new
proration invoice items in reverse to be created. By saying prorate = false
, we're
telling Stripe to change back to the original plan, but without creating any invoice
items. Yep, simply change the plan back.
Finally, call $stripeSubscription->save()
. Then, once again, re-throw the exception:
// ... lines 1 - 4 | |
use AppBundle\Entity\User; | |
use AppBundle\Subscription\SubscriptionPlan; | |
// ... lines 7 - 8 | |
class StripeClient | |
{ | |
// ... lines 11 - 153 | |
public function changePlan(User $user, SubscriptionPlan $newPlan) | |
{ | |
// ... lines 156 - 161 | |
try { | |
// immediately invoice them | |
$this->createInvoice($user); | |
} catch (\Stripe\Error\Card $e) { | |
$stripeSubscription->plan = $originalPlanId; | |
// prevent prorations discounts/charges from changing back | |
$stripeSubscription->prorate = false; | |
$stripeSubscription->save(); | |
throw $e; | |
} | |
// ... lines 173 - 174 | |
} | |
} |
Telling the User What Happened
That fixes the problem in Stripe. The last thing we need to do is tell the user what went wrong.
Open ProfileController::changePlanAction()
. Surround the changePlan()
call with -
you guessed it - one more try-catch block for that same exception: \Stripe\Error\Card
:
// ... lines 1 - 14 | |
class ProfileController extends BaseController | |
{ | |
// ... lines 17 - 141 | |
public function changePlanAction($planId) | |
{ | |
// ... lines 144 - 148 | |
try { | |
$stripeSubscription = $stripeClient->changePlan($this->getUser(), $plan); | |
} catch (\Stripe\Error\Card $e) { | |
// ... lines 152 - 154 | |
} | |
// ... lines 156 - 161 | |
} | |
} |
If this happens, return a new JsonResponse()
with a message key set to $e->getMessage()
:
// ... lines 1 - 14 | |
class ProfileController extends BaseController | |
{ | |
// ... lines 17 - 141 | |
public function changePlanAction($planId) | |
{ | |
// ... lines 144 - 148 | |
try { | |
$stripeSubscription = $stripeClient->changePlan($this->getUser(), $plan); | |
} catch (\Stripe\Error\Card $e) { | |
return new JsonResponse([ | |
'message' => $e->getMessage() | |
], 400); | |
} | |
// ... lines 156 - 161 | |
} | |
} |
This will be something like: "Your card was declined".
Oh, and give this a 400 status code so that jQuery knows that this AJAX call has failed.
Finally, in the template, add a .fail()
callback with a jqXHR
argument:
// ... lines 1 - 2 | |
{% block javascripts %} | |
// ... lines 4 - 7 | |
<script> | |
jQuery(document).ready(function() { | |
// ... lines 10 - 15 | |
$('.js-change-plan-button').on('click', function(e) { | |
// ... lines 17 - 24 | |
$.ajax({ | |
url: previewUrl | |
}).done(function(data) { | |
// ... lines 28 - 34 | |
swal({ | |
// ... lines 36 - 41 | |
}, function () { | |
$.ajax({ | |
url: changeUrl, | |
method: 'POST' | |
}).done(function() { | |
// ... lines 47 - 52 | |
}).fail(function(jqXHR) { | |
swal({ | |
title: 'Plan change failed!', | |
text: jqXHR.responseJSON.message, | |
type: 'error' | |
}); | |
}); | |
// todo - actually change the plan! | |
}); | |
}); | |
}) | |
}); | |
</script> | |
{% endblock %} | |
// ... lines 67 - 158 |
I'll paste in one last sweet alert popup that shows the message to the user.
Give it a Floor Run, See if it Plays
Let's test this whole big mess. Our current subscription is totally messed up, so go add a new, fresh Farmer Brent to your cart. Then, checkout with the functional, fake card.
Cool! In the account page, update the card to the one that will fail.
Before we upgrade, refresh the Customer page in Stripe to see how it looks. First, there's no customer balance, and our current subscription is for the Farmer Brent.
Ok, upgrade to the New Zealander! And... Plan change failed! That looks bad, but it's wonderful!
Reload the Customer page. First, the customer still has no account balance, that's good. Second, we can see the failed payment, but we're still on the Farmer Brent plan. And the $100 invoice is unpaid, but it's closed. Stripe won't try to pay this again.
Back on the Customer page, find Events at the bottom and click to view more. This tells the whole story: we upgraded to the New Zealander plan, the two proration invoice items were created, the invoice was created, the invoice payment failed, we updated the invoice to be closed, and finally downgraded back to the Farmer Brent plan.
WOW. Go find a co-worker and challenge them to break your setup. We are now, truly, rock solid.
Hello, with the actual Stripe version, there is several changements.
<u><b>Exception </b></u>:
\Stripe\Error\Card
=>\Stripe\Exception\CardException
In addition, there are certain attributes that no longer exist. And I can't seem to find an alternative.
$stripeSubscription->prorate = false;
The "<b>prorate</b>" attribute doesn't exist today.$invoice->closed = true;
The "<b>closed</b>" attribute doesn't exist today.Is there an alternative to these two attributes? No matter how much I look in the doc, I don't see anything similar
EDIT :
For the "<b>closed</b>" attribute : https://stripe.com/docs/billing/migration/invoice-states
They say that now you have to use the attribute "<b>status</b>" (and in our case, pass its value to "<b>void</b>". We can see an <b>Invoice object</b> example : https://stripe.com/docs/api/invoices/object
At first I tried stupidly:
$invoice->status = "void"
, but it returned an error in my logs :request.CRITICAL: Uncaught PHP Exception Stripe\Exception\InvalidRequestException: "Received unknown parameter: status"
I did not understand but after 30 minutes I found: https://stripe.com/docs/api/invoices/void
We have to do :
$invoice->voidInvoice();
Now it works.
However, weird thing, I have no error in the logs compared to the attribute "prorate" for subscriptions. Yet it should not exist?