Skip to content

Proration when a customer changes plan, and why you should let the portal do it

Upgrades, downgrades and seat changes each want different proration behaviour. What Stripe actually does, how to preview the amount, and when to build the flow yourself.

Stripe6 min readships at docs/solutions/stripe/proration-when-plans-change.md

Tags: stripe · proration · subscriptions · upgrades · billing · customer-portal

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:

ValueWhat happens
create_prorations (default)Credit + charge line items, applied to the next invoice
always_invoiceSame line items, but invoiced and charged now
noneNo 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" or billing_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() in src/lib/billing/stripe-objects.ts takes 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_due subscriptions. 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.