If you liked what you've learned so far, dive in!
Subscribe to get access to this tutorial plus
video, code and script downloads.
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.
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.
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!
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.
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.
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!
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.
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 :)
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!
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 ?
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
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!
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
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!
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 :)
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!
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?
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?
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?
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..
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"
// 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
}
}
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?