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.

Cancelation Edge-Case Bugs

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

I hope you now think that canceling and reactivating feels pretty easy! Well, it is! Except for 2 minor, edge-case bugs that have caused us problems in the past. Let's fix them now.

Problem 1: Canceling Past Due Accounts

First, go to the Stripe API docs and go down to subscription. You'll notice that one of the fields is called status, which has a number of different values. The most important ones for us are active, past_due, which means it's still in an active state, but we're having problems charging their card, and canceled.

Here's problem number 1: at the end of the month, Stripe will try to charge your user for the renewal. To do that, it will create an invoice and then charge that invoice. If, for some reason, the user's credit card can't be charged, the invoice remains created and Stripe will try to charge that invoice a few more times. That's something we'll talk a lot more about in a few minutes.

Now, imagine that the invoice has been created and we're having problems charging the user's credit card. Then, the user goes to our site and cancels. Since we're canceling "at period end", the invoice in Stripe won't be deleted, and Stripe will continue to try to charge that invoice a few more times. In other words, we will attempt to charge a user's credit card, after they cancel! Not cool!

To fix this, we need to fully cancel the user's subscription. That will close the invoice and stop future payment attempts on it.

Squashing the Bug: Fully Cancel

In StripeClient::cancelSubscription(), it's time to squash this bug. First, create a new variable called $cancelAtPeriodEnd and set it to true. Then, down below, set the at_period_end option to this variable:

... lines 1 - 8
class StripeClient
{
... lines 11 - 77
public function cancelSubscription(User $user)
{
... lines 80 - 84
$cancelAtPeriodEnd = true;
... lines 86 - 94
$sub->cancel([
'at_period_end' => $cancelAtPeriodEnd,
]);
... lines 98 - 99
}
... lines 101 - 116
}

Now, here's the trick: if $subscription->status == 'past_due', then it means that the invoice has been created and we're having problems charging it. In this case, set $cancelAtPeriodEnd to false:

... lines 1 - 8
class StripeClient
{
... lines 11 - 77
public function cancelSubscription(User $user)
{
... lines 80 - 84
$cancelAtPeriodEnd = true;
if ($sub->status == 'past_due') {
// past due? Cancel immediately, don't try charging again
$cancelAtPeriodEnd = false;
... lines 90 - 92
}
$sub->cancel([
'at_period_end' => $cancelAtPeriodEnd,
]);
... lines 98 - 99
}
... lines 101 - 116
}

This will cause the subscription to cancel immediately and close that invoice!

Problem 2: Canceling within 1 Hour of Renewal

But there's one other, weirder, but similar problem. At the end of the month, 1 hour before charging the user, Stripe creates the invoice. It then waits 1 hour, and tries to charge the user for the first time. So, if your user cancels within that hour, then we also need to fully cancel that subscription to prevent its invoice from being paid.

This is a little trickier: we basically need to see if the user is canceling within that one hour window. To figure that out, create a new variable called $currentPeriodEnd and set that to a \new DateTime() with the @ symbol and $subscription->current_period_end:

... lines 1 - 8
class StripeClient
{
... lines 11 - 77
public function cancelSubscription(User $user)
{
... lines 80 - 83
$currentPeriodEnd = new \DateTime('@'.$sub->current_period_end);
$cancelAtPeriodEnd = true;
... lines 86 - 99
}
... lines 101 - 116
}

This converts that timestamp into a \DateTime object.

Now, if $currentPeriodEnd < new \DateTime('+1 hour'), then this means that we're probably in that window and should set $cancelAtPeriodEnd = false:

... lines 1 - 8
class StripeClient
{
... lines 11 - 77
public function cancelSubscription(User $user)
{
... lines 80 - 83
$currentPeriodEnd = new \DateTime('@'.$sub->current_period_end);
$cancelAtPeriodEnd = true;
if ($sub->status == 'past_due') {
// past due? Cancel immediately, don't try charging again
$cancelAtPeriodEnd = false;
} elseif ($currentPeriodEnd < new \DateTime('+1 hour')) {
// within 1 hour of the end? Cancel so the invoice isn't charged
$cancelAtPeriodEnd = false;
}
$sub->cancel([
'at_period_end' => $cancelAtPeriodEnd,
]);
... lines 98 - 99
}
... lines 101 - 116
}

An easy way of thinking of this is, if the user is pretty close to the end of their period, then canceling now versus at period end, is almost the same. So, we'll just be careful.

But for this to work, your server's timezone needs to be set to UTC, which is the timezone used by the timestamps sent back from Stripe. If you're not sure, you could give yourself some more breathing room, but fully-canceling anyone's subscription that is within one day of the period end.

Fully Canceling in the Database

These fixes created a new problem! Now, when the user clicks the "Cancel Subscription" button, we might be canceling the subscription right now, and we need to update the database to reflect that.

To do that, first return the $stripeSubscription from the cancelSubscription() method:

... lines 1 - 8
class StripeClient
{
... lines 11 - 77
public function cancelSubscription(User $user)
{
... lines 80 - 94
$sub->cancel([
'at_period_end' => $cancelAtPeriodEnd,
]);
return $sub;
}
... lines 101 - 116
}

Then, in ProfileController, add $stripeSubscription = before the cancelSubscription() call:

... lines 1 - 11
class ProfileController extends BaseController
{
... lines 14 - 25
public function cancelSubscriptionAction()
{
... line 28
$stripeSubscription = $stripeClient->cancelSubscription($this->getUser());
$subscription = $this->getUser()->getSubscription();
... lines 32 - 46
}
... lines 48 - 63
}

Finally, we can use the status field to know whether or not the subscription has truly been canceled, or if it's still active until the period end. In other words, if $stripeSubscription->status == 'canceled', then the subscription is done! Else, we're canceling at period end and should just call deactivate():

... lines 1 - 11
class ProfileController extends BaseController
{
... lines 14 - 25
public function cancelSubscriptionAction()
{
... line 28
$stripeSubscription = $stripeClient->cancelSubscription($this->getUser());
$subscription = $this->getUser()->getSubscription();
if ($stripeSubscription->status == 'canceled') {
// the subscription was cancelled immediately
... line 35
} else {
$subscription->deactivateSubscription();
}
... lines 39 - 46
}
... lines 48 - 63
}

To handle full cancelation, open up Subscription and add a new public function called cancel(). Here, set $this->endsAt to right now, to guarantee that it will look canceled, and $this->billingPeriodEndsAt = null:

... lines 1 - 10
class Subscription
{
... lines 13 - 109
public function cancel()
{
$this->endsAt = new \DateTime();
$this->billingPeriodEndsAt = null;
}
... lines 115 - 129
}

In ProfileController, call it: $subscription->cancel():

... lines 1 - 11
class ProfileController extends BaseController
{
... lines 14 - 25
public function cancelSubscriptionAction()
{
... lines 28 - 32
if ($stripeSubscription->status == 'canceled') {
// the subscription was cancelled immediately
$subscription->cancel();
} else {
$subscription->deactivateSubscription();
}
... lines 39 - 46
}
... lines 48 - 63
}

And we are done!

Now, testing this is a bit difficult. So let's just make sure we didn't break anything major by hitting cancel. Perfect! And we can reactivate.

And this is why subscriptions are hard.

Leave a comment!

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

Hello ! The fact that Stripe bills 1 hour before is one thing. But if I am French and in my application, DateTime are configured with the time zone of my country. The time zone in France (Europe / Paris) adds one hour more compared to UTC. So I should put +2 hours instead of +1 hour in my case?

elseif($currentPeriodEnd < new \DateTime("+2 hours"))

You speak of this case there by saying that to be sure we can settle with a difference of 1 day for example. But can't that create problems?

Reply

Hey Kiuega,

Good question! IIRC it should work out of the box technically. When you get data from Stripe, those data is created according to the timezone in your application. So, if you have French timezone in you app, then all dates should reflect that offset. You can easily debug it by just dumping any DateTime object you got from Stripe and see if it's the correct time, i.e. matches your current time or no. Also, if you save any datetime object into DB, check how the dates looks like there, are they still matches your current time?

I hope this helps!

Cheers!

1 Reply
Mohammed Avatar
Mohammed Avatar Mohammed | posted 4 years ago

Hi,
I think the subscriptionStripeId should be set to null if the subscription is canceled, because it will be removed form stripe and having that in the database have no sense.

Reply
Default user avatar
Default user avatar Blueblazer172 | posted 5 years ago

Hi,

right now i have a really big problem... i can access the page but when i want to checkout any product or subscription the browser throws this error:

Error: Redirection error.
The called website redirects the request so that it can never be terminated.
This problem can sometimes occur when cookies are disabled or denied.

btw i'm trying to get it working in production(on a unix server). also i have validated the live stripe api.
You can have a look at https://www.codetex.me

Maybe you can reproduce the error and tell me what i've done wrong.

i've cleared the cache in production also and chmod 777 the cache folder. everything is working fine until you reach /checkout
could there be something wrong with my configuration?

i think there is something wrong with the connection to the stripe api

Thanks for any help :)

Reply

Hey Blueblazer172!

You've been busy! Good for you :).

First, ha! I love seeing our SheepShear club up live on the internet (even if only for testing). Awesome :).

So, I'm 99% sure that the problem is with https. You have probably required https for the checkout page, like we talked about in the first tutorial (good job!). But, for some reason (I'll talk about this next), even when you go to https://, your Symfony app doesn't think that the site is being accessed from https. It thinks it's being accessed via http. So, it redirects to https://. Then this happens again, and again, and again.

One way or another, there is some SSL misconfiguration, which is making it appear to your server that the request is actually http.

Is your site behind a load balancer or reverse proxy (for example, an EC2 Elastic load balancer)? If so, this is likely confusing Symfony... well not exactly confusing it - there are some security concerns that need to be dealt with. Specifically, if you have a load balancer that resolves the SSL cert for you, then by the time the request *actually* gets to your server, it is http. But, to indicate that the request *was* in fact originally https, your load balancer sets some special headers (specifically X-Forwarded-Proto https://developer.mozilla.o.... Symfony *is* smart enough to read this and realize that the request IS truly over https. However, for security concerns, by default, it does NOT read these headers. That is because if Symfony always read these headers, then a bad user could "fake" these headers. This would allow them to connect to your site over http, but fake like it is https. This specific example probably isn't a security concern, but there are a bunch of other X-Forwarded-* headers, and in general, they are not read by Symfony. In order to tell Symfony to read them, you need to configure your app to "trust" your proxy/load balancer.

Phew! Here's all the more info you need about that... in case you *are* behind a load balancer: http://symfony.com/doc/curr...

Let me know if that helps!

Reply
Default user avatar
Default user avatar Blueblazer172 | weaverryan | posted 5 years ago

Thanks Ryan :)
Another well explained reply. Great Support :)

I actually have a loadbalancer running on a nginx server :P
I've read the docs on symfony and changed the ip to the one for my balancer
in the app/config/config.yml but it wont work... should i clear the cache ?

Reply

Woohoo! Nice work! :)

Reply
Default user avatar
Default user avatar Blueblazer172 | weaverryan | posted 5 years ago

looks like i have still the same error grr..

here is my setting from the config.yml
http://imgur.com/Q4vxe4l

still the ERR_TOO_MANY_REDIRECTS error

Reply

Hmm, try 3 things to debug:

1) Try a new browser in incognito/private mode. Sometimes, a browser will cache that it should redirect on a URL, and make for weird behavior even after you've fixed it. I don't think that's the issue - but let's rule it out.

2) Try adding the code in this section - http://symfony.com/doc/current/request/load_balancer_reverse_proxy.html#but-what-if-the-ip-of-my-reverse-proxy-changes-constantly - which will trust ALL proxies. Depending on your setup, this might not be a good idea for security, but we should at least try it temporarily. If this fixes the problem, then we know something isn't correct with the IP address in trusted_proxies.

3) On production, go to any controller (even the homepage), and put this code:


var_dump($this->get('request_stack')->getCurrentRequest()->headers->all());die;

Then go to that page in https. What dumps out? You should see the X-Forwarded-Proto header (and a few other X-Forwarded headers). I want to make sure your load balancer is indeed setting the headers that Symfony is expecting.

We'll get to the bottom of it! Cheers!

Reply
Default user avatar
Default user avatar Blueblazer172 | weaverryan | posted 5 years ago

1) does not work :/
2) when i paste the first code into the app.php it will not work. look here: http://imgur.com/nSnl3Mv do i have to change the 127.0.0.1 to the one of my loadbalancer ?
3) the output from the dumped data is here: http://pastebin.com/C3Rhi772

Reply

Hey Blueblazer172!

1) It was a shot in the dark - didn't think it was the cause ;)

2) Oh, the docs are unclear! Make sure you paste that code AFTER the $request = line. So:


$request = Request::createFromGlobals();
Request::setTrustedProxies(array('127.0.0.1', $request->server->get('REMOTE_ADDR')));
// all the normal stuff

3) Great! I can see the x-forwarded-proto header, which tells me that your load balancer IS doing its job :). So, it's gotta be a trusted proxy problem. The above (2) should fix it, but isn't safe on production unless your server has no public IP address (I mean, it's only safe if your server can only be accessed through the load balancer, and never directly). But, either way, try it first. If it works, then we know the problem is with the trusted_proxies setting in config.yml.

Cheers!

1 Reply
Default user avatar
Default user avatar Blueblazer172 | weaverryan | posted 5 years ago

Ryan you are awasome :)

but as always i tried a fresh install and now everything works without setting anything in the trusted_proxies. Just have a look at it on www.codetex.me/checkout it redirects you to login witch is perfect :)

there is only one little thing left xD

let me explain it:

when i login and click on checkout without anything in the cart i can click on the 'Checkout for free' butten. My question is now how can i disable this button until any product or subscription is in the cart.

Currently it thows an exception with:

Nothing to invoice for customer
500 Internal Server Error - InvalidRequest

I think you have mentioned this error in some of your tuts, but i don't know it anymore where. If not may you explain how i can bypass this exception and give the user a flash message.

Thanks again for helping me like a thousand times :)
I <3 all your great work here at knpu. Its awasome :)

Reply

Yo Blueblazer172!

Awesome! It sounds like we're getting down to the last issues!

About this issue, you're correct that I mentioned it once! Basically, we need to (in our code) make sure that the user actually has something to buy before we start the checkout process. On our app, we have that ShoppingCart class, and it has a getProducts() method on it. In your checkout controller, I would add a check, something like this:


if (!$this->get('shopping_cart')->getProducts()) {
    $this->addFlash('danger', 'It looks like your cart is empty!');

    return $this->redirectToRoute('homepage');
}

If you want to do this check in Twig, you'll need to create a Twig extension, in order to create some new Twig function like get_cart_products(). We have some details about that on this other tutorial: https://knpuniversity.com/screencast/symfony-services/create-twig-extension

Cheers!

Reply
Default user avatar
Default user avatar Blueblazer172 | weaverryan | posted 5 years ago

thanks for sharing that:)
but when i add your line i cannot add a subscription to the cart unless i have a product added before. how can change that?

here is my checkoutAction():
http://pastebin.com/KejRgRxp

i've seen a function in the SubscriptionHelper.php called findPlan() but this searches only for the current plan i think and on checkout there is no plan actiive.

what can i do to archieve this?

Reply
Default user avatar

btw i have added access to the app_dev.php for everyone so you can access it if you need it.

also when i access the www.codetex.me/register/ path as a normal user it throws an 500 error, but when i access it with the app_dev.php there is no error and i can create users.

that is very confusing :P

what could there be wrong?

Reply

If it works in dev mode, I but doesn't work in prod - I suppose you have to clear the cache:
$ bin/console cache:clear --env=prod

You also could enforce it with deleting `var/cache/prod` folder manually on your server. Does it help?

Reply
Default user avatar
Default user avatar Blueblazer172 | Victor | posted 5 years ago

the /register is now working thanks :) i manually deleted the folder and now it works.

but the /checkout still does not work... i dont know what to do for thet grr..

Reply

Hey Blueblazer172 :)
When you get errors on production mode, you can check whats going on by reading the logs, you cand find them at "var/logs/{environment_name}.log"

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