Buy

Tracking Cancelations in our Database

When the user cancels, we need to somehow update the user's row in the subscription table so that we know this happened! And actually, it's kind of complicated: the user canceled, but the subscription should still be active until the end of the month. Then it'll really be canceled. So, how the heck can we manage this?

Using Subscription endsAt

Open ProfileController. Right after we cancel the subscription in Stripe, grab the subscription object by saying, $this->getUser()->getSubscription():

58 lines src/AppBundle/Controller/ProfileController.php
... lines 1 - 11
class ProfileController extends BaseController
{
... lines 14 - 25
public function cancelSubscriptionAction()
{
... lines 28 - 30
$subscription = $this->getUser()->getSubscription();
... lines 32 - 39
}
... lines 41 - 56
}

Here's the plan: we are not going to delete the subscription from the subscription table, because it's still active until the period end. Instead, we'll set the endsAt date field to when the subscription will expire:

125 lines src/AppBundle/Entity/Subscription.php
... lines 1 - 10
class Subscription
{
... lines 13 - 35
/**
* @ORM\Column(type="datetime", nullable=true)
*/
private $endsAt;
... lines 40 - 73
/**
* @return \DateTime
*/
public function getEndsAt()
{
return $this->endsAt;
}
public function setEndsAt(\DateTime $endsAt = null)
{
$this->endsAt = $endsAt;
}
... lines 86 - 123
}

That way, we'll know if the subscription is still active, meaning it's before the endsAt date, or it's fully canceled, because it's after the endsAt date.

At the bottom of Subscription, add a helper function to do this: public function deactivateSubscription():

125 lines src/AppBundle/Entity/Subscription.php
... lines 1 - 10
class Subscription
{
... lines 13 - 102
public function deactivateSubscription()
{
... lines 105 - 107
}
... lines 109 - 123
}

Since we know the user has paid through the end of the period, we can use that: $this->endsAt = $this->billingPeriodEndsAt. Also set $this->billingPeriodsEndsAt = null - just so we know that there won't be another bill at the end of this month:

125 lines src/AppBundle/Entity/Subscription.php
... lines 1 - 10
class Subscription
{
... lines 13 - 102
public function deactivateSubscription()
{
// paid through end of period
$this->endsAt = $this->billingPeriodEndsAt;
$this->billingPeriodEndsAt = null;
}
... lines 109 - 123
}

Cool! To deactivate the subscription in the controller, it's as easy as saying $subscription->deactivateSubscription() and then saving it to the database with the standard persist() and flush() Doctrine code:

58 lines src/AppBundle/Controller/ProfileController.php
... lines 1 - 11
class ProfileController extends BaseController
{
... lines 14 - 25
public function cancelSubscriptionAction()
{
... lines 28 - 30
$subscription = $this->getUser()->getSubscription();
$subscription->deactivateSubscription();
$em = $this->getDoctrine()->getManager();
$em->persist($subscription);
$em->flush();
... lines 36 - 39
}
... lines 41 - 56
}

And that should do it! Let's give this guy a try. Go to the account page, then press the new "Cancel Subscription" button. Ok, looks good! Check the customer page in the Stripe dashboard. Yes! The most recent subscription - the one we're dealing with in our code - is active but will cancel at the end of the month.

Showing "Canceled" on your Account

But if you look at the Account page, everything here still looks "Active". We updated the endsAt field on the subscription, but our code in this template isn't smart enough... yet.

Open account.html.twig. Hmm, I need an easy way to know whether or not a subscription is active, and if it is active, whether or not it's in this canceled state.

To help with this, let's create two methods inside the Subscription class. First, public function isActive():

125 lines src/AppBundle/Entity/Subscription.php
... lines 1 - 10
class Subscription
{
... lines 13 - 109
/**
* Subscription is active, or cancelled but still in "active" period
*
* @return bool
*/
public function isActive()
{
... line 117
}
... lines 119 - 123
}

Meaning: does the user still have an active subscription, even if it will cancel at the month's end? So, if $this->endsAt === null, then the subscription is definitely active. OR, $this->endsAt is greater than right now, new \DateTime():

125 lines src/AppBundle/Entity/Subscription.php
... lines 1 - 10
class Subscription
{
... lines 13 - 114
public function isActive()
{
return $this->endsAt === null || $this->endsAt > new \DateTime();
}
... lines 119 - 123
}

Meaning the subscription is canceled, but is ending in the future.

The second method we need is public function isCanceled():

125 lines src/AppBundle/Entity/Subscription.php
... lines 1 - 10
class Subscription
{
... lines 13 - 119
public function isCancelled()
{
... line 122
}
}

Meaning: if the subscription is active, has the user actually canceled it or not? This will simply be, return $this->endsAt !== null:

125 lines src/AppBundle/Entity/Subscription.php
... lines 1 - 10
class Subscription
{
... lines 13 - 119
public function isCancelled()
{
return $this->endsAt !== null;
}
}

Oh man, our setup is getting fancy! Let's get even fancier with one more helper method, this time in User. Add a new public function hasActiveSubscription():

94 lines src/AppBundle/Entity/User.php
... lines 1 - 11
class User extends BaseUser
{
... lines 14 - 83
public function hasActiveSubscription()
{
return $this->getSubscription() && $this->getSubscription()->isActive();
}
... lines 88 - 92
}

A User has an active subscription if they have a subscription object related to them and that subscription object isActive(). That'll save us some typing whenever we need to check whether or not a user has an active subscription.

Making the Account Template Awesome

Ok, back to the account template! This time, to be heros!

First, that "Cancel Subscription" button should only be there if the user has an active subscription. No problem! Add if app.user.hasActiveSubscription():

73 lines app/Resources/views/profile/account.html.twig
... lines 1 - 2
{% block body %}
<div class="nav-space">
<div class="container">
<div class="row">
<div class="col-xs-6">
<h1>
... lines 9 - 10
{% if app.user.hasActiveSubscription %}
... lines 12 - 16
<form action="{{ path('account_subscription_cancel') }}" method="POST" class="pull-right">
<button type="submit" class="btn btn-danger btn-xs">Cancel Subscription</button>
</form>
... line 20
{% endif %}
</h1>
... lines 23 - 63
</div>
... lines 65 - 67
</div>
</div>
</div>
... lines 71 - 73

But even here, if the user has already canceled their subscription, we don't want to keep showing them this button. Add another if: if app.user.subscription.isCancelled():

73 lines app/Resources/views/profile/account.html.twig
... lines 1 - 2
{% block body %}
<div class="nav-space">
<div class="container">
<div class="row">
<div class="col-xs-6">
<h1>
... lines 9 - 10
{% if app.user.hasActiveSubscription %}
{% if app.user.subscription.isCancelled %}
... lines 13 - 15
{% else %}
<form action="{{ path('account_subscription_cancel') }}" method="POST" class="pull-right">
<button type="submit" class="btn btn-danger btn-xs">Cancel Subscription</button>
</form>
{% endif %}
{% endif %}
</h1>
... lines 23 - 63
</div>
... lines 65 - 67
</div>
</div>
</div>
{% endblock %}
... lines 72 - 73

Then add a little "TODO" to add a re-activate button. If they've cancelled, they might remember how cool your service is and want to come back LATER and reactivate! In the else, show them the Cancel button.

Finish up the endif and the other endif. And actually, copy these first two lines: we need to re-use them further below. In the section that tells us whether or not we have an active subscription, we now have three states: "active", "active but canceled," and "none." Replace the old if statement with the two that you just copied. If the subscription is canceled, add label-warning and say "Canceled". Else, we know it's active:

73 lines app/Resources/views/profile/account.html.twig
... lines 1 - 2
{% block body %}
<div class="nav-space">
<div class="container">
<div class="row">
<div class="col-xs-6">
... lines 8 - 23
<table class="table">
<tbody>
<tr>
<th>Subscription</th>
<td>
{% if app.user.hasActiveSubscription %}
{% if app.user.subscription.isCancelled %}
<span class="label label-warning">Cancelled</span>
... lines 32 - 33
{% else %}
<span class="label label-success">Active</span>
{% endif %}
{% else %}
<span class="label label-default">None</span>
{% endif %}
</td>
</tr>
... lines 42 - 61
</tbody>
</table>
</div>
... lines 65 - 67
</div>
</div>
</div>
{% endblock %}
... lines 72 - 73

If a user doesn't have any type of active subscription, keep the "none" from before.

Finally, copy just the first if statement and scroll down to "Next Billing at". We should only show the next billing period if the user has an active subscription, not just if they have a related subscription object, because it could be canceled. Paste the if statement over this one:

73 lines app/Resources/views/profile/account.html.twig
... lines 1 - 2
{% block body %}
<div class="nav-space">
<div class="container">
<div class="row">
<div class="col-xs-6">
... lines 8 - 23
<table class="table">
<tbody>
... lines 26 - 41
<tr>
<th>Next Billing at:</th>
<td>
{% if app.user.hasActiveNonCancelledSubscription %}
{{ app.user.subscription.billingPeriodEndsAt|date('F jS') }}
{% else %}
n/a
{% endif %}
</td>
</tr>
... lines 52 - 61
</tbody>
</table>
</div>
... lines 65 - 67
</div>
</div>
</div>
{% endblock %}
... lines 72 - 73

Finally, do the same thing down below for the credit card: I don't want to confuse someone by showing them credit card information when they don't have a subscription:

73 lines app/Resources/views/profile/account.html.twig
... lines 1 - 2
{% block body %}
<div class="nav-space">
<div class="container">
<div class="row">
<div class="col-xs-6">
... lines 8 - 23
<table class="table">
<tbody>
... lines 26 - 51
<tr>
<th>Credit Card</th>
<td>
{% if app.user.hasActiveNonCancelledSubscription %}
{{ app.user.cardBrand }} ending in {{ app.user.cardLast4 }}
{% else %}
None
{% endif %}
</td>
</tr>
</tbody>
</table>
</div>
... lines 65 - 67
</div>
</div>
</div>
{% endblock %}
... lines 72 - 73

Phew! Ok, refresh! It's beautiful! There's our todo for the reactivate button and the subscription is canceled. But wait! We don't want the "Next Billing at" and credit card information to show up.

Ah, that's my bad! The hasActiveSubscription() returns true even if the user already cancelled it. Open User: let's add one more method: public function hasActiveNonCanceledSubscription():

94 lines src/AppBundle/Entity/User.php
... lines 1 - 11
class User extends BaseUser
{
... lines 14 - 88
public function hasActiveNonCancelledSubscription()
{
... line 91
}
}

Inside, return $this->hasActiveSubscription() && !$this->getSubscription()->isCancelled():

94 lines src/AppBundle/Entity/User.php
... lines 1 - 11
class User extends BaseUser
{
... lines 14 - 88
public function hasActiveNonCancelledSubscription()
{
return $this->hasActiveSubscription() && !$this->getSubscription()->isCancelled();
}
}

Use this method in both places in the Twig template:

73 lines app/Resources/views/profile/account.html.twig
... lines 1 - 2
{% block body %}
<div class="nav-space">
<div class="container">
<div class="row">
<div class="col-xs-6">
... lines 8 - 23
<table class="table">
<tbody>
... lines 26 - 41
<tr>
<th>Next Billing at:</th>
<td>
{% if app.user.hasActiveNonCancelledSubscription %}
{{ app.user.subscription.billingPeriodEndsAt|date('F jS') }}
{% else %}
n/a
{% endif %}
</td>
</tr>
<tr>
<th>Credit Card</th>
<td>
{% if app.user.hasActiveNonCancelledSubscription %}
{{ app.user.cardBrand }} ending in {{ app.user.cardLast4 }}
{% else %}
None
{% endif %}
</td>
</tr>
</tbody>
</table>
</div>
... lines 65 - 67
</div>
</div>
</div>
{% endblock %}
... lines 72 - 73

Refresh one more time! We got it!

But now that the user can cancel, let's make it possible for them to reactivate the subscription. It's actually an easy win.

Leave a comment!

  • 2016-09-22 weaverryan

    Haha, the only correct way to get that link to POST up to the server. But I think I see your point - with a bit more work, we could put that outside of the h1 and still have it float correctly - my bad for being lazy there :)

  • 2016-09-22 Oliver Davies

    A form within the h1? :(