# 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.

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"` 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

```ts
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:

```ts
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.

```ts
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:

```ts
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.

---

Agentic Boilerplate: A Next.js repo your agent already knows. Free during launch, then $99 once.

- Site map for agents: https://agenticboilerplate.com/llms.txt
- Public API: https://agenticboilerplate.com/openapi.json
- Contact: agenticstudio@gmail.com
