A customer on the $20/month plan upgrades to $50 on day 10 of a 30-day cycle. What do they pay, and when?
Stripe's default answer: it credits the unused portion of the old plan (20 × 20/30 = $13.33), charges the prorated new plan (50 × 20/30 = $33.33), and puts the $20 difference on the next invoice, not on a charge today.
That last part surprises people. The customer clicks Upgrade, gets access immediately, is charged nothing today, and sees a larger bill in 20 days. Every one of those is a support ticket unless your UI says so.
The three behaviours
Every plan change takes a proration_behavior:
| Value | What happens |
|---|---|
create_prorations (default) | Credit + charge line items, applied to the next invoice |
always_invoice | Same line items, but invoiced and charged now |
none | No adjustment at all; the new price applies from the next cycle |
And the honest mapping to product decisions:
- Upgrade →
always_invoice. The customer wanted more, gets it now, and pays the difference now. This is the least surprising behaviour and the best for cash flow. It also fails loudly if their card declines, which is information you want at the moment of upgrade rather than 20 days later. - Downgrade →
none, scheduled at period end. Refunding the difference for a downgrade is a policy choice most companies do not make, and issuing credits that sit on an account is its own support burden. Let them keep the higher tier until the period they paid for ends. - Seat changes →
create_prorations. Quantity moves up and down often; invoicing on every change is noise.
Billing mode changes the arithmetic
Since the Clover API (2025-09-30.clover), new subscriptions default to
flexible billing mode, and startCheckout in this repo sets it explicitly.
Three differences matter for plan changes:
- Credits follow what was paid. A credit proration refunds the amount the customer was actually charged for the unused time, not the current price. If the price or a discount changed since the last invoice, the credit follows the old invoice.
- The billing cycle anchor never resets on its own. Classic mode reset it,
and invoiced at once, when a customer moved to a price with a different
interval or from a free price to a paid one. Flexible mode does not: a
free-to-paid upgrade puts pending items on the next invoice unless you pass
proration_behavior: "always_invoice"orbilling_cycle_anchor: "now". - Intervals can mix. A monthly seat price can sit next to a yearly add-on,
so each item has its own
current_period_end.periodEnd()insrc/lib/billing/stripe-objects.tstakes the furthest one.
Subscriptions created before an upgrade stay classic until you migrate them
with stripe.subscriptions.migrate(id, { billing_mode: { type: "flexible" } }).
There is no way back to classic, so migrate one in test mode first.
Doing it in code
const subscription = await stripe.subscriptions.retrieve(subscriptionId);
const item = subscription.items.data[0];
if (!item) throw new Error("Subscription has no items.");
await stripe.subscriptions.update(subscriptionId, {
items: [{ id: item.id, price: newPriceId }],
proration_behavior: "always_invoice",
// Anchor the billing cycle where it was, so an upgrade does not silently
// move the customer's renewal date.
billing_cycle_anchor: "unchanged",
});
Two things that are easy to get wrong:
Update the item, do not add one. Passing items: [{ price: newPriceId }]
without the existing item's id adds a second subscription item, so the
customer is billed for both plans. Always retrieve first and pass item.id.
Never cancel-and-recreate. It loses the billing cycle anchor, the discount, the trial history and the subscription id every one of your rows references. It also usually double-charges.
For a scheduled downgrade, use a subscription schedule (or set the change to apply at period end) rather than a cron job that remembers to do it later.
Show the number before they click
Never make a customer discover a proration amount on their next invoice. Preview it:
const preview = await stripe.invoices.createPreview({
customer: customerId,
subscription: subscriptionId,
subscription_details: {
items: [{ id: item.id, price: newPriceId }],
proration_behavior: "always_invoice",
proration_date: Math.floor(Date.now() / 1000),
},
});
// preview.amount_due is what they will be charged, in minor units.
Render it as plain English: "You'll be charged $33.33 today, and $50.00 monthly
from 14 June." If you pass a proration_date to the preview, pass the same
value to the actual update. Otherwise the number you showed and the number you
charge are computed at different instants and will differ by cents, which is
worse than not showing one.
The strong recommendation: use the customer portal
Stripe's hosted customer portal handles plan changes, proration previews, cancellation (immediate or at period end), payment method updates, invoice history and tax IDs. It is configured in the dashboard, localised, accessible, and maintained by someone else.
const session = await stripe.billingPortal.sessions.create({
customer: customerId,
return_url: `${origin}/billing`,
});
redirect(session.url);
That is what openBillingPortal() in this project does, through the Stripe
adapter's createPortal. On /billing, a subscriber's "Switch to Team" button
opens the portal rather than a second checkout: startCheckout refuses a new
subscription while one is live, with already-subscribed. In the portal's
configuration you choose which products customers may switch between and which
proration behaviour applies: the same decisions as above, made once, in a
dashboard, without shipping code.
One portal behaviour depends on billing mode. On a flexible subscription, a
cancellation "at period end" sets cancel_at to the period end and leaves
cancel_at_period_end false. The projection stores both, and scheduledEnd()
turns them into the one date the billing page shows. Never gate "is this
subscription ending?" on the flag alone.
Build the flow yourself only when you need something the portal cannot express: a custom upgrade path with usage-based add-ons, an in-app wizard that is part of your onboarding, or an approval step. Recognise that as a real feature with real maintenance, not an afternoon.
Handling the aftermath
A plan change produces several webhooks within a second or two:
customer.subscription.updated, usually invoice.created, and with
always_invoice an invoice.paid (or invoice.payment_failed). They can arrive
out of order.
The handler that survives this is the one that does not try to interpret the sequence:
case "customer.subscription.updated":
return subscriptionEvents(event.data.object.id); // re-read, then map
Re-fetch the subscription and upsert the projection. Whatever order the events land in, you converge on what Stripe currently says, which is the only thing that is true.
Do not compute a customer's new plan from the event payload. Do not keep a local
counter of what they used to be on. Read items.data[0].price.id and map it to
a plan through the catalogue. In this project subscriptionInput in
stripe-objects.ts does that: the BILLING_PRICE_* env map first, then the
price's lookup key (the catalogue id billing:sync-plans sets), never the
checkout metadata, which still names the plan the customer first bought. Add
every price the portal offers to src/lib/pricing.ts, or a switch lands on a
row with plan_slug null. The page shows it as paid, but hasPlan() grants
no paid plan for it, so the customer loses access until the price is mapped.
Edge cases worth naming
- Different currencies. A subscription cannot switch to a price in another currency. Cancel and start a new subscription, and be explicit with the customer about it.
- Mid-cycle downgrade with credit. If you do choose to credit, the money becomes a customer balance that applies to future invoices. It is not a refund. Customers reliably read "credit" as "money back". Say which you mean.
- Trialing subscriptions. Changing plan during a trial does not prorate (there is nothing to prorate). The trial continues with the new price.
past_duesubscriptions. Let the payment recover before allowing an upgrade, or you are adding debt to an account that already cannot pay.
The short version
Upgrades bill now; downgrades take effect at period end; seats prorate. Always
show the amount before the click, computed with the same proration_date you
then use. Update the existing subscription item, never add one, never
cancel-and-recreate. And unless you have a specific reason not to, let the
customer portal do all of it.