Changing your Plan from Monthly to Yearly

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

So far, we only offer monthly plans. But sheep love commitment, so they've been asking for yearly options. Well, great news! After all that upgrade stuff we just handled, this is going to be easy.

Creating the New Plans

First, in Stripe's dashboard, we need to create two new plans. Call the first "Farmer Brent yearly" and for the total... how about 99 X 10: so $990, per year.

Then, add the New Zealander yearly, set to 1990, billed yearly.

Cool! I'm not going to update our checkout to allow these plans initially, because, honestly, that's super easy: just create some new links to add these plans to your cart, and you're done.

Nope, we'll skip straight to the hard stuff: allowing the user to change between monthly and yearly plans.

Adding the SubscriptionPlan Objects

First, we need to add these plans to our system. Open the SubscriptionPlan class. To distinguish between monthly and yearly plans, add a new property called duration: this will be a string, either monthly or yearly. At the top, I love constants, so create: const DURATION_MONTHLY = 'monthly' and const DURATION_YEARLY = 'yearly':

... lines 1 - 4
class SubscriptionPlan
{
const DURATION_MONTHLY = 'monthly';
const DURATION_YEARLY = 'yearly';
... lines 9 - 44
}

Next, add a $duration argument to the constructor, but default it to monthly. Set the property below:

... lines 1 - 4
class SubscriptionPlan
{
... lines 7 - 15
private $duration;
public function __construct($planId, $name, $price, $duration = self::DURATION_MONTHLY)
{
... lines 20 - 22
$this->duration = $duration;
}
... lines 25 - 44
}

Finally, I'll use the "Code"->"Generate" menu, or Command+N on a Mac, select "Getters" and then choose duration. That gives me a nice getDuration() method:

... lines 1 - 4
class SubscriptionPlan
{
... lines 7 - 40
public function getDuration()
{
return $this->duration;
}
}

In SubscriptionHelper, we create and preload all of our plans. Copy the two monthly plans, paste them, update their keys to have yearly and add the last argument for the yearly duration:

... lines 1 - 8
class SubscriptionHelper
{
... lines 11 - 15
public function __construct(EntityManager $em)
{
... lines 18 - 31
$this->plans[] = new SubscriptionPlan(
'farmer_brent_yearly',
'Farmer Brent',
990,
SubscriptionPlan::DURATION_YEARLY
);
$this->plans[] = new SubscriptionPlan(
'new_zealander_yearly',
'New Zealander',
1990,
SubscriptionPlan::DURATION_YEARLY
);
}
... lines 46 - 131
}

Now, these are at least valid plans inside the system.

The Duration Change UI

Here's the goal: on the account page, next to the "Next Billing at" text, I want to add a link that says "bill yearly" or "bill monthly". When you click this, it should follow the same workflow we just built for upgrading a plan: it should show the cost, then make the change.

In ProfileController::accountAction(), add yet another variable here called $otherDurationPlan:

... lines 1 - 14
class ProfileController extends BaseController
{
... lines 17 - 19
public function accountAction()
{
$currentPlan = null;
$otherPlan = null;
$otherDurationPlan = null;
... lines 25 - 42
}
... lines 44 - 168
}

This will eventually be the SubscriptionPlan object for the other duration of the current plan. So if I have the monthly Farmer Brent, this will be set to the yearly Farmer Brent plan.

To find that plan, open SubscriptionHelper and add a new function called findPlanForOtherDuration() with a $currentPlanId argument:

... lines 1 - 8
class SubscriptionHelper
{
... lines 11 - 74
public function findPlanForOtherDuration($currentPlanId)
{
if (strpos($currentPlanId, 'monthly') !== false) {
$newPlanId = str_replace('monthly', 'yearly', $currentPlanId);
} else {
$newPlanId = str_replace('yearly', 'monthly', $currentPlanId);
}
return $this->findPlan($newPlanId);
}
... lines 85 - 131
}

I'll paste in some silly code here. This relies on our naming conventions to switch between monthly and yearly plans.

Back in the controller, copy the $otherPlan line, paste it, then update the variable to $otherDurationPlan and the method to findPlanForOtherDuration():

... lines 1 - 14
class ProfileController extends BaseController
{
... lines 17 - 19
public function accountAction()
{
... lines 22 - 23
$otherDurationPlan = null;
if ($this->getUser()->hasActiveSubscription()) {
... lines 26 - 31
$otherDurationPlan = $this->get('subscription_helper')
->findPlanForOtherDuration($currentPlan->getPlanId());
}
... lines 35 - 42
}
... lines 44 - 168
}

Pass that into the template as a new variable:

... lines 1 - 14
class ProfileController extends BaseController
{
... lines 17 - 19
public function accountAction()
{
... lines 22 - 23
$otherDurationPlan = null;
if ($this->getUser()->hasActiveSubscription()) {
... lines 26 - 31
$otherDurationPlan = $this->get('subscription_helper')
->findPlanForOtherDuration($currentPlan->getPlanId());
}
return $this->render('profile/account.html.twig', [
... lines 37 - 40
'otherDurationPlan' => $otherDurationPlan,
]);
}
... lines 44 - 168
}

Cool!

In account.html.twig, scroll down to the Upgrade Plan button. Copy that whole thing. Then, keep scrolling to the "Next Billing at" section. If the user has a subscription, paste the upgrade button:

... lines 1 - 67
{% block body %}
<div class="nav-space">
<div class="container">
<div class="row">
<div class="col-xs-6">
... lines 73 - 88
<table class="table">
<tbody>
... lines 91 - 116
<tr>
<th>Next Billing at:</th>
<td>
{% if app.user.hasActiveNonCancelledSubscription %}
{{ app.user.subscription.billingPeriodEndsAt|date('F jS') }}
<button class="btn btn-xs btn-link pull-right js-change-plan-button"
data-preview-url="{{ path('account_preview_plan_change', {'planId': otherDurationPlan.planId}) }}"
data-plan-name="{{ otherDurationPlan.name }} {{ otherDurationPlan.duration }}"
data-change-url="{{ path('account_execute_plan_change', {'planId': otherDurationPlan.planId}) }}"
>
Bill {{ otherDurationPlan.duration }}
</button>
... lines 130 - 131
{% endif %}
</td>
</tr>
... lines 135 - 148
</tbody>
</table>
</div>
... lines 152 - 160
</div>
</div>
</div>
{% endblock %}
... lines 165 - 166

And since this process will be the same as upgrading, we can re-use this exactly. Just change otherPlan to otherDurationPlan... in all 4 places. Update the text to "Bill" and then otherDurationPlan.duration. So, this will say something like "Bill yearly".

Dump the Upcoming Invoice

Before we try this, go back into ProfileController and find previewPlanChangeAction(). The truth is, changing a plan from monthly to yearly should be identical to upgrading a plan. But, it's not quite the same. To help us debug an issue we're about to see, dump the $stripeInvoice variable:

... lines 1 - 14
class ProfileController extends BaseController
{
... lines 17 - 122
public function previewPlanChangeAction($planId)
{
... lines 125 - 127
$stripeInvoice = $this->get('stripe_client')
->getUpcomingInvoiceForChangedSubscription(
$this->getUser(),
$plan
);
dump($stripeInvoice);
... lines 134 - 141
}
... lines 143 - 168
}

And now that I've told you it won't work, let's try it out! Refresh the account page. Then click the new "Bill yearly" link. Ok:

You will be charged $792.05 immediately

Wait, that doesn't seem right. The yearly plan is $990 per year. Then, if you subtract approximately $99 from that as a credit, it should be something closer to $891. Something is not quite right.

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