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.
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.
| What | When it changes |
|---|---|
| The proration invoice | Never, on a downgrade |
| Quota allowances | Immediately, to the lower plan |
| Features and limits | At the next period rollover |
| Existing credits | Kept 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