Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine
This course is archived!
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.

Monthly to Yearly: The Billing Period Change

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

We just found out that this amount - $792 - doesn't seem right! Open the web debug toolbar and click to see the profiler for the "preview change" AJAX call that returned this number. Click the Debug link on the left. This is a dump of the upcoming invoice. And according to it, the user will owe $891.05. Wait, that sounds exactly right!

The number we just saw is different because, remember, we start with amount_due, but then subtract the plan total to remove the extra line item that Stripe adds. Back then, we had three line items: the two prorations and a line item for the next, full month.

But woh, now there's only two line items: the partial-month discount and a charge for the full, yearly period.

Changing Duration changes Billing Period

Stripe talks about this oddity in their documentation: when you change to a plan with a different duration - so monthly to yearly or vice-versa - Stripe bills you immediately and changes your billing date to start today.

So if you're normally billed on the first of the month and you change from monthly to yearly on the 15th, you'll be credited half of your monthly subscription and then charged for a full year. That yearly subscription will start immediately, on the 15th of that month and be renewed in one year, on the 15th.

For us, this means that the amount_due on the Invoice is actually correct: we don't need to adjust it. In ProfileController, create a new variable called $currentUserPlan set to $this->get('subscription_helper')->findPlan() and pass it $this->getUser()->getSubscription()->getStripePlanId():

... 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
);
$currentUserPlan = $this->get('subscription_helper')
->findPlan($this->getUser()->getSubscription()->getStripePlanId());
... lines 136 - 146
}
... lines 148 - 173
}

Now, if $plan - which is the new plan - $plan->getDuration() matches the $currentUserPlan->getDuration(), then we should correct the total. Otherwise, if the duration is changing, the $total is already perfect:

... lines 1 - 14
class ProfileController extends BaseController
{
... lines 17 - 122
public function previewPlanChangeAction($planId)
{
... lines 125 - 133
$currentUserPlan = $this->get('subscription_helper')
->findPlan($this->getUser()->getSubscription()->getStripePlanId());
$total = $stripeInvoice->amount_due;
// contains the pro-rations
// *plus* - if the duration matches - next cycle's amount
if ($plan->getDuration() == $currentUserPlan->getDuration()) {
// subtract plan price to *remove* next the next cycle's total
$total -= $plan->getPrice() * 100;
}
... lines 145 - 146
}
... lines 148 - 173
}

Since this looks totally weird, I'll tweak my comment to mention that amount_due contains the extra month charge only if the duration stays the same.

Ok! Go back and refresh! Click "Bill yearly". Yes! That looks right: $891.06.

Duration Change? Don't Invoice

Because of this behavior difference when the duration changes, we need to fix one other spot: in StripeClient::changePlan(). Right now, we manually create an invoice so that the customer is charged immediately. But... we don't need to do that in this case: Stripe automatically creates and pays an Invoice when the duration changes.

In fact, trying to create an invoice will throw an error! Let's see it. First, update your credit card to one that will work.

Now, change to Bill yearly and confirm. The AJAX call should fail... and it does! Open the profiler for that request and find the Exception:

Nothing to invoice for customer

Obviously, we need to avoid this. In StripeClient, add a new variable: $currentPeriodStart that's set to $stripeSubscription->current_period_start:

... lines 1 - 8
class StripeClient
{
... lines 11 - 153
public function changePlan(User $user, SubscriptionPlan $newPlan)
{
$stripeSubscription = $this->findSubscription($user->getSubscription()->getStripeSubscriptionId());
$currentPeriodStart = $stripeSubscription->current_period_start;
... lines 159 - 181
}
}

That's the current period start date before we change the plan.

After we change the plan, if the duration is different, the current period start will have changed. Surround the entire invoicing block with if $stripeSubscription->current_period_start == $currentPeriodStart:

... lines 1 - 8
class StripeClient
{
... lines 11 - 153
public function changePlan(User $user, SubscriptionPlan $newPlan)
{
... lines 156 - 157
$currentPeriodStart = $stripeSubscription->current_period_start;
... lines 159 - 166
if ($stripeSubscription->current_period_start == $currentPeriodStart) {
try {
// immediately invoice them
$this->createInvoice($user);
} catch (\Stripe\Error\Card $e) {
$stripeSubscription->plan = $originalPlanId;
// prevent prorations discounts/charges from changing back
$stripeSubscription->prorate = false;
$stripeSubscription->save();
throw $e;
}
}
... lines 180 - 181
}
}

In other words: only invoice the customer manually if the subscription period hasn't changed. I think we should add a note above this: this can look really confusing!

... lines 1 - 8
class StripeClient
{
... lines 11 - 153
public function changePlan(User $user, SubscriptionPlan $newPlan)
{
... lines 156 - 157
$currentPeriodStart = $stripeSubscription->current_period_start;
... lines 159 - 163
// if the duration did not change, Stripe will not charge them immediately
// but we *do* want them to be charged immediately
// if the duration changed, an invoice was already created and paid
if ($stripeSubscription->current_period_start == $currentPeriodStart) {
try {
// immediately invoice them
$this->createInvoice($user);
} catch (\Stripe\Error\Card $e) {
$stripeSubscription->plan = $originalPlanId;
// prevent prorations discounts/charges from changing back
$stripeSubscription->prorate = false;
$stripeSubscription->save();
throw $e;
}
}
... lines 180 - 181
}
}

Take it for a Test Drive

But, now it should work! Reset things by going to the pricing page and buying a brand new monthly subscription. Now, head to your account page and update it to yearly. The amount - $891 - looks right, so hit OK.

Yes! Plan changed! My option changed to "Bill monthly" and the "Next Billing at" date is August 10th - one year from today. We should probably print the year.

In Stripe, under payments, we have one for $891, and the customer is on the Farmer Brent yearly plan.

Winning!

Leave a comment!

12
Login or Register to join the conversation
Kiuega Avatar
Kiuega Avatar Kiuega | posted 3 years ago | edited

Hello ! There is something that has not been tested. It is a question of passing from an annual subscription to monthly. And it causes a bug. If I click on "Bill monthly", he says to me: You will have a balance of $0 that will be automatically applied to future invoices!

Is there a solution ?

Reply

I have managed to solve this by using the ending_balance field of the Invoice. Also, I have noticed a separate issue using amount_due when downgrading from a plan to another having the same duration. amount_due works fine when there is an upgrade, but not when there is a downgrade.

Here is my code (apologies for the ugly nested else condition AND please note that I am using different logic in the condition that compare the plan durations)


$total = $invoice->total;

// contains the pro-rations
// *plus* - if the duration matches - next cycle's amount
if ($userPlan->getDuration() === $newPlan->getDuration()) {
    $total -= $newPlan->getPrice() * 100;
} else {
    if ($invoice->ending_balance < 0) {
        $total = $invoice->ending_balance;
    }
Reply

Hey Martin,

Thanks for sharing your solution with others! Though, proration is a bit more complex than just price difference between 2 plans. It also takes into account how many "unused" time left from the old subscription, so the actual logic should be more complex, otherwise it will show the correct proration only in the beginning of your old subscription.

I hope this helps!

Cheers!

Reply

I've further improved the code removing my ugly nested else / if conditions. The previous solution I posted was also working - this one is just cleaner. I can confirm that this has been tested using 16 different scenarios including 6 days after initial subscription and the total always comes out calculated correctly and matching what Stripe actually charges.


// contains the pro-rations *plus* the next cycle's amount *plus* any negative ending balance
$total = $invoice->amount_due + $invoice->ending_balance;

// contains the pro-rations
// *plus* - if the duration matches - next cycle's amount
if ($plan->getDuration() == $currentUserPlan->getDuration()) {
    // subtract plan price to *remove* next the next cycle's total
    $total -= $plan->getPrice() * 100;
}

Plan upgrade with same duration
Plan downgrade with same duration
Plan changed to lower duration
Plan changed to higher duration
Plan upgrade + higher duration
Plan upgrade + lower duration
Plan downgrade + higher duration
Plan downgrade + lower duration
(everything tested moments after the initial subscription + 6 days after initial subscription)

1 Reply

Hey Martin,

Thank you for sharing this with others! This sounds cool

Cheers!

Reply

Hey Kiuega!

Hmm. This could be due to another change in newer versions of the API. In the version of the API used in this tutorial, the total property that's returned from Stripe would be a negative number when going from yearly to monthly. After completing the change, that negative balance would be added to the "Customer" so that future invoices would automatically be lower. If you're seeing 0 for total, I bet that data is stored in another place or there is some other little tweak that needs to be made to get things working correctly.

Let me know what you find out :). We use this exact feature here on SymfonyCasts.com but using an older API version.

Cheers!

Reply
Kiuega Avatar

Okay! I will also come back to this in a while to see if there is anything to do for the new version and I will tell you here

1 Reply
Default user avatar
Default user avatar Chris | posted 5 years ago | edited

Hello @KNP team,

first of all, this tutorial is awesome and so well explained. It`s one of my favorite tutorials right now :)

But strange things happen now. When I switch from the yearly plan to the monthly, it says "You will have an approximate balance of $0 that will be automatically applied to future invoices!". Why does he get a $0 balance, switching from yearly to monthly? Shouldn`t the customer get notified about the potential forthcoming credit balance change when the subscription plan switches to monthly, i.e. "You will have an approximate balance of 890$ that will be automatically applied to future invoices!" ?

Because, when I proceed the following step and look into Stripe it actually says that the credit balance is $890.99, in the "preview", sweet alert does not reflect this (instead it gives me a 0 credit balance back).

The problem is, that when I switch from yearly to monthly, Stripe actually gives me in the upcoming invoice an amount_due of 0.
The two lines are (edited)

  1. "amount" => -99000 "description" => "Unused time on Farmer Brent (yearly) after 21 Sep 2017"
  2. "amount" => 9900 "description" => null

Because of amount_due = 0, previewPlanChangeAction in ProfileController:


    /**
     * @Route("/profile/plan/change/preview/{planId}", name="account_preview_plan_change")
     */
    public function previewPlanChangeAction($planId)
    {
        $plan = $this->get('subscription_helper')
            ->findPlan($planId);

        $stripeInvoice = $this->get('stripe_client')
            ->getUpcomingInvoiceForChangedSubscription(
                $this->getUser(),
                $plan
            );

        $currentUserPlan = $this->get('subscription_helper')
            ->findPlan($this->getUser()->getSubscription()->getStripePlanId());

        // contains the prorations
        // *plus* - if the duration matches - the next cycle's amount
        $total = $stripeInvoice->amount_due;
        dump($stripeInvoice);

        if ($plan->getDuration() == $currentUserPlan->getDuration()) {
            $total -= $plan->getPrice() * 100;
        }

        return new JsonResponse(['total' => $total/100]);
    }

returns a JSON Response of total = 0.

But don`t we want to inform the customer about his existing credit balance, after switching his plan?

Many thanks in advance,

Chris

Reply
Kiuega Avatar

Same problem, is there a solution ?

Reply

Hey Kiuega !

Ah, we missed replying to the original comment! A fail for us! As I just replied above - https://symfonycasts.com/sc... - I'm guessing that on a newer API version, the data must be available somewhere else. As Chris mentioned, after you complete the change from yearly to monthly, the account balance *is* correctly applied to the user. So the question is: where is Stripe exposing that data before the change? It's *possible* that they're not exposing it now, but that would surprise me.

Do you see any other data in the upcoming invoice that's useful?

Cheers!

Reply

In src/AppBundle/StripeClient.php on line 167 you compare
$stripeSubscription->current_period_start == $currentPeriodStart.
But $currentPeriodStart on line 158 equals $stripeSubscription->current_period_start.
Do you sure what comparison "if(true)" has the meaning?

Reply

Hey alexchromets

Nice question, and actually it look's like it is but we have a note about it on our code blocks. The thing here is that the currentPerdiodStart may change after saving the new plan, and it has an special meaning for Stripe:


        // if the duration did not change, Stripe will not charge them immediately
        // but we *do* want them to be charged immediately
        // if the duration changed, an invoice was already created and paid
        if ($stripeSubscription->current_period_start == $currentPeriodStart) {

Cheers!

Reply
Cat in space

"Houston: no signs of life"
Start the conversation!

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