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!

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
        "twig/twig": "^1.24.1" // v1.35.2
    },
    "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
    }
}