If you liked what you've learned so far, dive in!
Subscribe to get access to this tutorial plus
video, code and script downloads.
When the user clicks "OK", we'll make an AJAX request to the server and then tell Stripe to actually make the change.
In ProfileController
, add the new endpoint: public function changePlanAction()
.
Set its URL to /profile/plan/change/execute/{planId}
and name it account_execute_plan_change
.
Add the $planId
argument:
... lines 1 - 14 | |
class ProfileController extends BaseController | |
{ | |
... lines 17 - 137 | |
/** | |
* @Route("/profile/plan/change/execute/{planId}", name="account_execute_plan_change") | |
* @Method("POST") | |
*/ | |
public function changePlanAction($planId) | |
{ | |
... lines 144 - 154 | |
} | |
} |
This will start just like the previewPlanChangeAction()
endpoint: copy its $plan
code and paste it here:
... lines 1 - 14 | |
class ProfileController extends BaseController | |
{ | |
... lines 17 - 137 | |
/** | |
* @Route("/profile/plan/change/execute/{planId}", name="account_execute_plan_change") | |
* @Method("POST") | |
*/ | |
public function changePlanAction($planId) | |
{ | |
$plan = $this->get('subscription_helper') | |
->findPlan($planId); | |
$stripeClient = $this->get('stripe_client'); | |
$stripeSubscription = $stripeClient->changePlan($this->getUser(), $plan); | |
// causes the planId to be updated on the user's subscription | |
$this->get('subscription_helper') | |
->addSubscriptionToUser($stripeSubscription, $this->getUser()); | |
return new Response(null, 204); | |
} | |
} |
To actually change the plan in Stripe, we need to fetch the Subscription, set its plan to the new id, and save. Super easy!
Open StripeClient
and add a new function called changePlan()
with two arguments:
the User
who wants to upgrade and the SubscriptionPlan
that they want to change
to:
... lines 1 - 4 | |
use AppBundle\Entity\User; | |
use AppBundle\Subscription\SubscriptionPlan; | |
... lines 7 - 8 | |
class StripeClient | |
{ | |
... lines 11 - 144 | |
public function changePlan(User $user, SubscriptionPlan $newPlan) | |
{ | |
... lines 147 - 155 | |
} | |
} |
Then, fetch the \Stripe\Subscription
for the User with $this->findSubscription()
passing it $user->getSubscription()->getStripeSubscriptionId()
:
... lines 1 - 8 | |
class StripeClient | |
{ | |
... lines 11 - 144 | |
public function changePlan(User $user, SubscriptionPlan $newPlan) | |
{ | |
$stripeSubscription = $this->findSubscription($user->getSubscription()->getStripeSubscriptionId()); | |
... lines 148 - 155 | |
} | |
} |
Now, update that: $stripeSubscription->plan = $newPlan->getPlanId()
:
... lines 1 - 146 | |
$stripeSubscription = $this->findSubscription($user->getSubscription()->getStripeSubscriptionId()); | |
$stripeSubscription->plan = $newPlan->getPlanId(); | |
... lines 150 - 158 |
Finally, send that to Stripe with $stripeSubscription->save()
:
... lines 1 - 146 | |
$stripeSubscription = $this->findSubscription($user->getSubscription()->getStripeSubscriptionId()); | |
$stripeSubscription->plan = $newPlan->getPlanId(); | |
$stripeSubscription->save(); | |
... lines 151 - 158 |
Ok, that was easy. And now you probably expect there to be a "catch" or a gotcha that makes this harder. Well... yea... there totally is. Sorry.
I told you earlier that Stripe doesn't charge the customer right now: it waits until the end of the cycle and then bills for next month's renewal, plus what they owe for upgrading this month. We want to bill them immediately.
How? Simple: by manually creating an Invoice and paying it. Remember: when you create an Invoice, Stripe looks for all unpaid invoice items on the customer. When you change the plan, this creates two new invoice items for the negative and positive plan proration. So if we invoice the user right now, it will pay those invoice items.
And hey! We already have a method to do that called createInvoice()
. Heck it
even pays that invoice immediately:
... lines 1 - 8 | |
class StripeClient | |
{ | |
... lines 11 - 53 | |
public function createInvoice(User $user, $payImmediately = true) | |
{ | |
$invoice = \Stripe\Invoice::create(array( | |
"customer" => $user->getStripeCustomerId() | |
)); | |
if ($payImmediately) { | |
// guarantee it charges *right* now | |
$invoice->pay(); | |
} | |
return $invoice; | |
} | |
... lines 67 - 156 | |
} |
In our function, call $this->createInvoice()
and pass it $user
:
... lines 1 - 8 | |
class StripeClient | |
{ | |
... lines 11 - 144 | |
public function changePlan(User $user, SubscriptionPlan $newPlan) | |
{ | |
$stripeSubscription = $this->findSubscription($user->getSubscription()->getStripeSubscriptionId()); | |
$stripeSubscription->plan = $newPlan->getPlanId(); | |
$stripeSubscription->save(); | |
// immediately invoice them | |
$this->createInvoice($user); | |
... lines 154 - 155 | |
} | |
} |
Finally, return $stripeSubscription
at the bottom - we'll need that in a minute:
... lines 1 - 8 | |
class StripeClient | |
{ | |
... lines 11 - 144 | |
public function changePlan(User $user, SubscriptionPlan $newPlan) | |
{ | |
$stripeSubscription = $this->findSubscription($user->getSubscription()->getStripeSubscriptionId()); | |
$stripeSubscription->plan = $newPlan->getPlanId(); | |
$stripeSubscription->save(); | |
// immediately invoice them | |
$this->createInvoice($user); | |
return $stripeSubscription; | |
} | |
} |
Back in the controller, call this with $stripeSubscription = $this->get('stripe_client')
then ->changePlan($this->getUser(), $plan)
:
... lines 1 - 14 | |
class ProfileController extends BaseController | |
{ | |
... lines 17 - 141 | |
public function changePlanAction($planId) | |
{ | |
$plan = $this->get('subscription_helper') | |
->findPlan($planId); | |
$stripeClient = $this->get('stripe_client'); | |
$stripeSubscription = $stripeClient->changePlan($this->getUser(), $plan); | |
... lines 149 - 154 | |
} | |
} |
Ok, the plan is upgraded! Well, in Stripe. But we also need to update the subscription row in our database.
When a user buys a new subscription, we call a method on SubscriptionHelper
called
addSubscriptionToUser()
. We pass it the new \Stripe\Subscription
and the User
:
... lines 1 - 8 | |
class SubscriptionHelper | |
{ | |
... lines 11 - 60 | |
public function addSubscriptionToUser(\Stripe\Subscription $stripeSubscription, User $user) | |
{ | |
$subscription = $user->getSubscription(); | |
if (!$subscription) { | |
$subscription = new Subscription(); | |
$subscription->setUser($user); | |
} | |
$periodEnd = \DateTime::createFromFormat('U', $stripeSubscription->current_period_end); | |
$subscription->activateSubscription( | |
$stripeSubscription->plan->id, | |
$stripeSubscription->id, | |
$periodEnd | |
); | |
$this->em->persist($subscription); | |
$this->em->flush($subscription); | |
} | |
... lines 79 - 106 | |
} |
Then it guarantees that the user has a subscription row in the table with the correct
data, like the plan id, subscription id, and $periodEnd
date.
Now, the only thing we need to update right now is the plan ID: both the subscription ID and period end haven't changed. But that's ok: we can still safely reuse this method.
In ProfileController
, add $this->get('subscription_helper')->addSubscriptionToUser()
passing it $stripeSubscription
and $this->getUser()
:
... lines 1 - 14 | |
class ProfileController extends BaseController | |
{ | |
... lines 17 - 141 | |
public function changePlanAction($planId) | |
{ | |
... lines 144 - 147 | |
$stripeSubscription = $stripeClient->changePlan($this->getUser(), $plan); | |
// causes the planId to be updated on the user's subscription | |
$this->get('subscription_helper') | |
->addSubscriptionToUser($stripeSubscription, $this->getUser()); | |
... lines 153 - 154 | |
} | |
} |
And that's everything. At the bottom... well, we don't really need to return
anything to our JSON. So just return a new Response()
with null
as the content
and a 204
status code:
... lines 1 - 14 | |
class ProfileController extends BaseController | |
{ | |
... lines 17 - 141 | |
public function changePlanAction($planId) | |
{ | |
... lines 144 - 147 | |
$stripeSubscription = $stripeClient->changePlan($this->getUser(), $plan); | |
// causes the planId to be updated on the user's subscription | |
$this->get('subscription_helper') | |
->addSubscriptionToUser($stripeSubscription, $this->getUser()); | |
return new Response(null, 204); | |
} | |
} |
This doesn't do anything special: 204
simply means that the operation was successful,
but the server has nothing it wishes to say back.
Copy the route name, then head to the template to make this work.
First, find the button, copy the data-preview-url
attribute, and paste it. Name
the new one data-change-url
and update the route name:
... lines 1 - 61 | |
{% block body %} | |
<div class="nav-space"> | |
<div class="container"> | |
<div class="row"> | |
<div class="col-xs-6"> | |
... lines 67 - 82 | |
<table class="table"> | |
<tbody> | |
<tr> | |
<th>Subscription</th> | |
<td> | |
{% if app.user.hasActiveSubscription %} | |
{% if app.user.subscription.isCancelled %} | |
... lines 90 - 92 | |
{% else %} | |
... lines 94 - 97 | |
<button class="btn btn-xs btn-link pull-right js-change-plan-button" | |
data-preview-url="{{ path('account_preview_plan_change', {'planId': otherPlan.planId}) }}" | |
data-plan-name="{{ otherPlan.name }}" | |
data-change-url="{{ path('account_execute_plan_change', {'planId': otherPlan.planId}) }}" | |
> | |
Change to {{ otherPlan.name }} | |
</button> | |
{% endif %} | |
... lines 106 - 107 | |
{% endif %} | |
</td> | |
</tr> | |
... lines 111 - 134 | |
</tbody> | |
</table> | |
</div> | |
... lines 138 - 146 | |
</div> | |
</div> | |
</div> | |
{% endblock %} | |
... lines 151 - 152 |
Above in the JavaScript, set a new changeUrl
variable to $(this).data('change-url')
:
... lines 1 - 2 | |
{% block javascripts %} | |
... lines 4 - 7 | |
<script> | |
jQuery(document).ready(function() { | |
... lines 10 - 15 | |
$('.js-change-plan-button').on('click', function(e) { | |
... lines 17 - 20 | |
var previewUrl = $(this).data('preview-url'); | |
var changeUrl = $(this).data('change-url'); | |
... lines 23 - 56 | |
}) | |
}); | |
</script> | |
{% endblock %} | |
... lines 61 - 152 |
Then, scroll down to the bottom: this callback function will be executed if the
user clicks the "Ok" button to confirm the change. Make the AJAX call here: set the
url
to changeUrl
, the method
to POST
, and attach one more success function:
... lines 1 - 2 | |
{% block javascripts %} | |
... lines 4 - 7 | |
<script> | |
jQuery(document).ready(function() { | |
... lines 10 - 15 | |
$('.js-change-plan-button').on('click', function(e) { | |
... lines 17 - 24 | |
$.ajax({ | |
url: previewUrl | |
}).done(function(data) { | |
... lines 28 - 34 | |
swal({ | |
title: 'Change to '+planName, | |
text: message, | |
type: "info", | |
showCancelButton: true, | |
closeOnConfirm: false, | |
showLoaderOnConfirm: true | |
}, function () { | |
$.ajax({ | |
url: changeUrl, | |
method: 'POST' | |
}).done(function() { | |
... lines 47 - 52 | |
}); | |
// todo - actually change the plan! | |
}); | |
}); | |
}) | |
}); | |
</script> | |
{% endblock %} | |
... lines 61 - 152 |
Inside that, call Sweet Alert to tell the user that the plan was changed! Let's also add some code to reload the page after everything:
... lines 1 - 2 | |
{% block javascripts %} | |
... lines 4 - 7 | |
<script> | |
jQuery(document).ready(function() { | |
... lines 10 - 15 | |
$('.js-change-plan-button').on('click', function(e) { | |
... lines 17 - 24 | |
$.ajax({ | |
url: previewUrl | |
}).done(function(data) { | |
... lines 28 - 34 | |
swal({ | |
title: 'Change to '+planName, | |
text: message, | |
type: "info", | |
showCancelButton: true, | |
closeOnConfirm: false, | |
showLoaderOnConfirm: true | |
}, function () { | |
$.ajax({ | |
url: changeUrl, | |
method: 'POST' | |
}).done(function() { | |
swal({ | |
title: 'Plan changed!', | |
type: 'success' | |
}, function() { | |
location.reload(); | |
}); | |
}); | |
// todo - actually change the plan! | |
}); | |
}); | |
}) | |
}); | |
</script> | |
{% endblock %} | |
... lines 61 - 152 |
OK! Let's do this! Refresh the page! Click to change to the "New Zealander". $99.88 - that looks right, now press "Ok". And ... cool! I think it worked! When the page reloads, our plan is the "New Zealander" and we can downgrade to the "Farmer Brent".
In the Stripe dashboard, open payments, click the one for $99.88, and open its Invoice. Oh, it's a thing of beauty: this has the two line items for the change.
If you check out the customer, their top subscription is now to the New Zealander plan.
So we're good. Except for one last edge-case.
Hey Kiuega
Interesting solution, great that you found how to fix it.
However why are you using return
inside preConfirm
?
Cheers!
Hi,
I've one problem with this chapter 26... When i try to execute the plan change, i take an error : Nothing to invoice for customer. Could you help me to understand what happened ?
In Stripe i can see the upgrade of the customer plan, but in my DB i think there is no change... I don't understand because i've made the same change in my code :(
Hey Christophe!
Hmm. Well, I *do* know this error: it happens when you have no invoice items to invoice. The most likely cause is that Stripe is automatically creating the Invoice for you... and then when you try to create the Invoice, all the InvoiceItems have been charged already. This is *exactly* what we talk about here: https://knpuniversity.com/s.... But, Stripe should only automatically create the invoice if the duration changes (e.g. monthly to yearly). Is your duration staying the same (e.g. upgrading from one monthly plan to another)?
Let me know! It could be a small bug somewhere in your code... or potentially a behavior change in Stripe (but I hope not!)
Cheers!
// 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, I'm using SweetAlert2 so it's not the same thing.
I've this code :