Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine
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.

Start your All-Access Pass
Buy just this tutorial for $12.00

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.

Leave a comment!

5
Login or Register to join the conversation
Kiuega Avatar
Kiuega Avatar Kiuega | posted 3 years ago | edited

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?

Reply

Hi again Kiuega!

Thanks for sharing your questions and discoveries again :). About the last point:

<blockquote>However, weird thing, I have no error in the logs compared to the attribute "prorate" for subscriptions. Yet it should not exist?
</blockquote>

It looks to me that the prorate attribute hasn't been removed - it's just deprecated. https://stripe.com/docs/api/subscriptions/update#update_subscription-prorate

The docs say:

<blockquote>prorate optional DEPRECATED
...
Use proration_behavior=create_prorations as a replacement for prorate=true and proration_behavior=none for prorate=false.
</blockquote>

So it looks like prorate still works - but you can use the other proration_behavior property to use the latest way.

Let me know if the whole thing is working!

Cheers!

Reply
Kiuega Avatar

Okay I see! I will come back to this later. I am currently working on a set of files that would allow us to manage the whole Stripe aspect from our application. Namely products, subscriptions, coupons, everything you need. Because I haven't found anything that already does the same thing and it can be really cool to be able to integrate this into any project! I don't know how you do it at home.
Currently I'm on the coupons part, it's longer than I thought

https://zupimages.net/up/20...

Reply
Anton B. Avatar
Anton B. Avatar Anton B. | posted 3 years ago

The 27 chapter is not correctly named when downloading

Reply

Hey Anton,

Ah, I see, the wrong number! I just renamed it, so now it should be "27-plan-change-payment-failure". Thank you for letting us know about it!

Cheers!

Reply
Cat in space

"Houston: no signs of life"
Start the conversation!

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.

What PHP libraries does this tutorial use?

// composer.json
{
    "require": {
        "php": ">=5.5.9, <7.4",
        "symfony/symfony": "3.1.*", // v3.1.10
        "doctrine/orm": "^2.5", // v2.7.2
        "doctrine/doctrine-bundle": "^1.6", // 1.6.8
        "doctrine/doctrine-cache-bundle": "^1.2", // 1.3.0
        "symfony/swiftmailer-bundle": "^2.3", // v2.6.2
        "symfony/monolog-bundle": "^2.8", // v2.12.1
        "symfony/polyfill-apcu": "^1.0", // v1.3.0
        "sensio/distribution-bundle": "^5.0", // v5.0.22
        "sensio/framework-extra-bundle": "^3.0.2", // v3.0.26
        "incenteev/composer-parameter-handler": "^2.0", // v2.1.2
        "friendsofsymfony/user-bundle": "~2.0.1", // v2.0.1
        "stof/doctrine-extensions-bundle": "^1.2", // v1.2.2
        "stripe/stripe-php": "^3.15", // v3.23.0
        "doctrine/doctrine-migrations-bundle": "^1.1", // v1.2.1
        "phpunit/phpunit": "^5.5", // 5.7.20
        "composer/package-versions-deprecated": "^1.11" // 1.11.99
    },
    "require-dev": {
        "sensio/generator-bundle": "^3.0", // v3.1.4
        "symfony/phpunit-bridge": "^3.0", // v3.3.0
        "hautelook/alice-bundle": "^1.3", // v1.4.1
        "doctrine/data-fixtures": "^1.2" // v1.2.2
    }
}
userVoice