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/yearIn 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:

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



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



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 resetCompare the routing of the three invoice kinds:
billing_reason | Path | What happens to credits |
|---|---|---|
subscription_create | fulfillSubscriptionPayment | Full reset to the new plan's allowance |
subscription_cycle | fulfillSubscriptionPayment | Full reset (a new period opens) |
subscription_update | fulfillSubscriptionUpgrade | Incremental 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:
- 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
- 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:
| Direction | What happens to the drip |
|---|---|
| Monthly → Yearly | Re-anchored: this order is the first month's delta, and 11 more periods go into the schedule |
| Yearly → Monthly | The 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:
| Where | What to look for |
|---|---|
/dashboard/billing | Correct balance and current plan |
/dashboard/my-orders | A subscription_upgrade order appears |
credit_transactions | Coherent type, delta and snapshot |
The subscriptions table | After 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.