Menu

Subscription Changes

Good to know

Since v4.0.0, mid-cycle plan changes are handled by the boilerplate; you no longer implement them yourself. The 3.x guide that told you to "develop it from the comments" is obsolete. This page covers how to configure the entry point, and exactly how the built-in logic computes things.

The relationship between products and prices

One important premise first: a Stripe subscription change switches between different products:

  • Supported: Product A → Product B
  • Not supported: Price 1 of Product A → Price 2 of Product A

So design your products by feature tier rather than cramming every price into one product:

Product1:
├── Price: $10/month
└── Price: $100/year
 
Product2:
├── Price: $20/month
└── Price: $200/year
 
Product3:
├── Price: $50/month
└── Price: $500/year

In config/pricing.ts these become entries like starter-monthly / starter-yearly / pro-monthly / pro-yearly, each pointing at its own Price ID.

Enabling the plan-change entry point

Open the Stripe Customer Portal settings:

Stripe Customer Portal

Enable the change entry under Subscriptions and configure the switching options:

Stripe Customer portal subscription
Stripe Customer portal subscription
Stripe Customer portal subscription

Once configured, a customer who clicks "manage subscription" on /dashboard/billing sees the plan-change option in the portal:

Stripe Customer portal subscription
Stripe Customer portal subscription
Stripe Customer portal subscription

When a customer changes plan, Stripe computes the difference and issues a prorated invoice.

What happens after the change

The customer changes plan in the Billing Portal
        │
        ▼
Stripe issues a prorated invoice
        │
        ▼
invoice.paid (billing_reason = subscription_update)
        │
        ▼
lib/billing/stripe.ts recognizes a mid-cycle change
        │
        ▼
fulfillSubscriptionUpgrade()          ← incremental, not a reset

Compare the routing of the three invoice kinds:

billing_reasonPathWhat happens to credits
subscription_createfulfillSubscriptionPaymentFull reset to the new plan's allowance
subscription_cyclefulfillSubscriptionPaymentFull reset (a new period opens)
subscription_updatefulfillSubscriptionUpgradeIncremental top-up, no reset

How the credit difference is computed

The formula in one line:

delta = round( max(0, new plan's monthly allowance − credits already granted in this window) × remaining-time fraction of the window )

The subscription bucket only grows; it is never reset here. Each factor carries one job:

Factor one: the window high-water mark (credits already granted)

Derived by summing the subscription-bucket deltas of every subscription_grant row in this window from the ledger — not by reading the old plan's configured value.

That buys two things:

  1. Natural immunity to webhook reordering — it never depends on "which plan was active before", a piece of state that out-of-order events can scramble
  2. It doubles as the anti-farming cap — cumulative grants within one window can never exceed the highest plan allowance ever reached in that window

Factor two: the remaining-time fraction

Keeps the grant strictly commensurate with the prorated amount actually charged.

What the two factors together prevent

Farming credits by flip-flopping: downgrading first produces a credit balance that offsets the next upgrade invoice (so almost nothing is actually paid). But the high-water mark does not fall on a downgrade, so the second upgrade's delta is always 0 — no credits can be minted.

Skimming a month at the end of a period: only the fraction matching the remaining time is granted, so a cheap end-of-period upgrade cannot capture a whole month's difference.

No amount-paid threshold

An upgrade invoice fully offset by credit balance — costing the customer $0 — is topped up like any other. The high-water mark already makes it safe, so no "only grant if they paid enough" check is needed.

Downgrades

Neither grant nor clawback.

The high-water mark does not fall, so nothing is granted at the time of the downgrade; the next period's cycle invoice (subscription_cycle) takes the full-reset path and naturally brings the allowance down to the new plan's level.

This avoids the experience disaster of "downgrading instantly confiscates credits the customer already holds".

Cross-interval changes (month ↔ year)

The payment re-bases the billing period, so the yearly drip schedule has to follow:

DirectionWhat happens to the drip
Monthly → YearlyRe-anchored: this order is the first month's delta, and 11 more periods go into the schedule
Yearly → MonthlyThe drip schedule is cleared
Yearly → Yearly (same interval)The drip is untouched. Later settlements read monthly_credits off the subscription row, which syncSubscription has already updated, so the new allowance applies automatically

Two edge cases

A subscription already settled as a duplicate: its local state is terminal, so only the trace of the money is kept (a 0-credit order row) and nothing further is granted.

A proration invoice with 0 amount and 0 delta (a pure downgrade or a lateral move): neither money nor credits, so no order row is written.

Other providers

Creem and PayPal do not take this mid-cycle path today — their plan changes depend on what each portal supports, and payment events are handled as ordinary subscription payments. If either is your primary provider, guide customers to cancel and re-subscribe when they want a different plan.

Testing

Stripe's Test Clock advances time and lets you simulate a full subscription lifecycle.

Scenarios to cover:

Same-interval changes

  • Monthly upgrade: starter-monthly → pro-monthly
  • Monthly downgrade: pro-monthly → starter-monthly
  • Yearly upgrade / downgrade: likewise

Cross-interval changes

  • Monthly to yearly: any monthly plan → its yearly counterpart
  • Yearly to monthly: any yearly plan → its monthly counterpart

Anti-farming verification

  • Downgrade, then immediately upgrade back; confirm the second upgrade's delta is 0
  • Upgrade near the end of a period; confirm only the remaining-time fraction is granted

After each change, check these four places:

WhereWhat to look for
/dashboard/billingCorrect balance and current plan
/dashboard/my-ordersA subscription_upgrade order appears
credit_transactionsCoherent type, delta and snapshot
The subscriptions tableAfter a cross-interval change, monthly_credits / remaining_grants / next_grant_at match expectations

In development mode you can also use /dashboard/credit-usage-example to quickly verify the spending path.