Guides
Build guide

Proration on plan change in Next.js, and why a downgrade needs the opposite setting

Proration on plan change in Next.js: why upgrades and downgrades need opposite proration behavior, and how to read direction without mistaking a yearly plan for a bigger one.

Dharmendra Jagodana9 min read

In short

Proration is not one setting, it is a direction. An upgrade invoices the difference now. A downgrade should not, because the customer keeps the old plan features until the period ends, so a credit refunds what they are still using. Read direction from monthly-equivalent price, never the ticket amount.

Every proration tutorial ends at the same line.

proration_behavior: 'always_invoice';

It is right half the time. On an upgrade it does exactly what you want: Stripe credits the unused part of the old plan, charges the new one from today, and cuts an invoice for the difference. On a downgrade the same line hands money back for features the customer has not stopped using yet.

Proration is not a setting you configure once. It is a decision that depends on which way the change goes, and getting the direction wrong is not a rounding error - it is a refund you did not mean to issue.

What you are building

A plan switcher in a Next.js app. A customer on Launch picks Grow and sees an invoice for roughly $25 on a mid-month change, because the gap between $49 and $99 is $50 and half the period is still to come. The same customer going the other way sees no invoice at all, keeps their Grow features until the period ends, and the change shows up as pending rather than done.

Before you start

A BuildBase project with the billing module, a plan group with at least two plan versions, and a workspace that already has an active subscription. If there is no subscription yet, that is a checkout flow, not a plan change.

Step one: read where the subscription is now

useSubscriptionManagement gives you the current subscription, the plans available to switch to, and the mutation, in one hook.

import {
  useSaaSWorkspaces,
  useSubscriptionManagement,
} from '@buildbase/sdk/react';

function PlanSwitcher() {
  const { currentWorkspace } = useSaaSWorkspaces();
  const { subscription, planGroup, loading, updateSubscription, refetch } =
    useSubscriptionManagement(currentWorkspace?._id);

  if (loading) return <p>Loading plans</p>;
  if (!subscription?.subscription) return <CheckoutPrompt />;

  return <PlanList planGroup={planGroup} onPick={updateSubscription} />;
}

You should see planGroup.plans come back as plan versions, each with its own _id. That _id is what you pass to change the plan. Not the plan id, and not the plan name. Versions are the priced copies, which is what lets pricing move without rewriting what last year's customers agreed to.

Step two: ask for the change

const result = await updateSubscription(planVersionId, {
  billingInterval: 'monthly',
  successUrl: window.location.href,
  cancelUrl: window.location.href,
});

Notice what you did not pass. There is no proration flag in that call, and that is deliberate: the server works out the direction and picks the behavior, because the client is the wrong place to decide how much to bill someone.

What it decides, and why, is worth knowing even if you never touch it. Direction is read from the monthly-equivalent price of the old and new base prices, not from the amounts on the tickets. A yearly Launch price and a monthly Grow price are not comparable numbers. Launch at $490 a year is the smaller commitment and the bigger integer, so a naive comparison reads the move to Grow at $99 a month as a downgrade and skips the invoice you were owed.

Then the behavior follows the direction: an upgrade invoices immediately, a downgrade does not prorate at all.

So what happens if the price lookup itself fails? The direction stays null, and the two decisions that depend on it handle that null differently. Billing never sees it as a null at all: plannedDowngrade ? 'none' : 'always_invoice' reads falsy, so an unknown direction gets billed as an upgrade. Feature sync checks explicitly, and only when the direction is unknown does it fall back to the sign of the latest invoice.

Whenever the direction is known, both agree. The unknown case is where it gets interesting, because that fallback does not work. It tests amount_due < 0, and amount_due does not go below zero. Stripe's own field documentation says it "may be 0" when account credit applies, which is exactly the downgrade case: the credit lands on the customer's balance and the invoice reports nothing due. The negative shows up in total and in ending_balance, neither of which the fallback reads.

So both paths land on "upgrade" when the direction is unknown, by two different routes: billing because null is falsy, feature sync because the sign it is looking for never arrives. A downgrade whose price comparison failed gets invoiced like an upgrade and has its features cut on the spot. Rare, because it needs a Stripe call to fail first. Worth knowing it fails in that direction rather than the safe one.

One thing you cannot do from here is quote the customer a figure first. There is no proration preview in the API today, so "you will be charged $25 today" is not a number you can render before the change commits. Describe what will happen in words, and show them the invoice after.

Step three: handle both shapes of the result

updateSubscription returns one of two things, and the split is not about payment. A workspace with a Stripe subscription gets that subscription changed in place. A workspace without one gets a checkout session, because there is nothing to change yet - it is a first purchase wearing a plan change's clothes.

if ('checkoutUrl' in result) {
  // No subscription to modify. Send them through checkout.
  window.location.href = result.checkoutUrl;
  return;
}

await refetch();

Then the part that actually bites. A change in place does not mean money moved. The update runs with pending_if_incomplete, so the subscription can come back active while the proration invoice sits open behind a declined card or a 3DS prompt, and the feature sync waits for invoice.paid before it runs. A 200 is not a receipt. An app that renders one as "you're on Grow now" has shipped a bug, and it is the kind you find out about from a customer rather than a log.

The API does tell you, on the branch where it matters. When payment is outstanding the response carries paymentRequired, the invoice, and a paymentUrl to send the customer to. On the clean path all three are simply absent, so do not destructure them blind.

In TypeScript you will need a cast to read them. None of the three are declared on ISubscriptionUpdateResponse, and that is not a version you can upgrade out of: they are missing from the whole type surface in 0.0.70, the latest published SDK, the same as in 0.0.56. The route sends them, the types do not mention them, so narrow on 'checkoutUrl' in result first and cast for the rest.

Step four: show a downgrade as pending, not done

A downgrade is not one event. Four things move on different clocks, and a UI that treats them as one is how support tickets start.

WhatWhen it changes
The proration invoiceNever, on a downgrade
Quota allowancesImmediately, to the lower plan
Features and limitsAt the next period rollover
Existing creditsKept until they expire on their own

So the subscription reports the new plan straight away while the old plan's features are still switched on. Render that gap honestly. "Your plan changes to Launch on 14 October" is true; "You are now on Launch" is not, and the customer who still sees their Grow features will believe the UI over the invoice.

The credit half surprises people most. What happens to credits the customer already paid for? They stay, and the smaller allowance arrives at the next renewal rather than clawing back what is already in the account. Credits and quotas behave differently here because they are different things, and a downgrade is where that distinction stops being academic.

The failure modes

Comparing ticket prices to find the direction. The yearly price is always the larger number, so this one survives testing: everybody tests monthly-to-monthly. Add a yearly plan and a real upgrade starts skipping its invoice.

Using always_invoice in both directions. A downgrade that credits back unused time pays for the same period twice: once in the refund, once in the features that stay on until rollover. Stripe will not stop you: direction is yours to work out, and the setting that is correct going up is a refund coming down.

Letting a plan change eat reported usage. Two Stripe behaviors collide here. Removing a metered subscription item that has usage records is refused unless clear_usage is set, and clear_usage throws the usage away. always_invoice does not rescue it, which we found out the expensive way: measured against live Stripe, a downgrade over 10,101 reported units produced an invoice with exactly one line - the unused-time credit - and the usage was gone. Every plan change was discarding a period of overage, on precisely the accounts most likely to have any. The fix is to convert reported usage into an invoice item before the subscription items change, which also puts it on the bill as its own readable line. If you are metering per call, that guide covers the recording side.

Offering the switcher to a subscription that cannot take it. Only active and trialing subscriptions can change plan. Everything else returns a 400 with a reason attached - past due, in dunning, paused, suspended, incomplete, or already cancelling - and "Cannot upgrade a past-due subscription. Please resolve the payment issue first." is a better thing to render than a generic error. A past-due customer trying to upgrade is someone actively trying to give you money, so route them to the payment problem instead of a dead end. Two more refusals live in the same place: you cannot move a workspace between plan groups, and you cannot change its currency.

Assuming a trial upgrades like a subscription. A workspace still trialing has no paid period to prorate against. Changing plan sets trial_end to now, which ends the trial on the spot and starts the paid plan from that moment rather than at the end of the trial window. Our own trial is 7 days and takes no card, so the first real charge and the plan change can be the same click.

Not expecting the seat line to move. A plan change does reconcile seats, and that is the surprise. Billable seats are recomputed against the new plan's included allowance, so the seat item gets repriced, added, or deleted by a change the customer thought was only about the base plan. Five people on Launch are paying for two seats past the three included. Move them to Grow, which includes eight, and the seat line does not shrink - it disappears. Per-seat billing has its own failure modes.

I have not run this exact file end to end as one app. Each snippet matches the canonical SDK usage in our own docs and constants rather than being a variant written for this post, and the proration behavior described is what the billing service does today, not what it should do.

npm i @buildbase/sdk
billing
nextjs
stripe

Frequently Asked Questions

Should a downgrade be prorated the same way as an upgrade?

No. An upgrade invoices the difference now. A downgrade should not be prorated, because the customer keeps the higher plan features until the period ends, so crediting back the unused time refunds what they are still using.

How do you tell an upgrade from a downgrade across billing intervals?

Compare monthly-equivalent amounts, not the raw price. A yearly price is always the larger number, so comparing the ticket amount classifies a yearly-to-monthly upgrade as a downgrade and skips the invoice.

What happens to credits when a workspace downgrades mid-cycle?

They stay. The customer already paid for this period, so existing credits run until they expire naturally at period end, and the lower plan's credit allowance is granted at the next renewal rather than immediately. Quota allowances are different: they move to the lower plan straight away.

Does proration_behavior always_invoice bill reported metered usage?

No. The invoice it cuts carries the base-plan proration and nothing else. Reported usage has to be moved onto an invoice item before the subscription items change, or removing the metered item discards it.

Ship it

Create a project and run this guide against your own workspace.

7-day free trialNo credit card requiredCancel anytime