Buy

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:

118 lines src/AppBundle/StripeClient.php
... 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:

118 lines src/AppBundle/StripeClient.php
... 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:

118 lines src/AppBundle/StripeClient.php
... 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:

118 lines src/AppBundle/StripeClient.php
... 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:

118 lines src/AppBundle/StripeClient.php
... 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:

65 lines src/AppBundle/Controller/ProfileController.php
... 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():

65 lines src/AppBundle/Controller/ProfileController.php
... 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:

131 lines src/AppBundle/Entity/Subscription.php
... 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():

65 lines src/AppBundle/Controller/ProfileController.php
... 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!

  • 2017-03-14 Diego Aguiar

    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"

  • 2017-03-13 Blueblazer172

    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?

  • 2017-03-13 weaverryan

    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/s...

    Cheers!

  • 2017-03-13 Blueblazer172

    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 :)

  • 2017-03-13 weaverryan

    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!

  • 2017-03-13 Blueblazer172

    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..

  • 2017-03-13 Victor Bocharsky

    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?

  • 2017-03-10 Blueblazer172

    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?

  • 2017-03-10 Blueblazer172

    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

  • 2017-03-10 weaverryan

    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/curr... - 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!

  • 2017-03-09 Blueblazer172

    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

  • 2017-03-09 weaverryan

    Woohoo! Nice work! :)

  • 2017-03-09 Blueblazer172

    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 ?

  • 2017-03-08 weaverryan

    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!

  • 2017-03-07 Blueblazer172

    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 :)