Building a Self-Service Plan Change Flow
Every SaaS customer eventually wants to move between plans, and forcing them to email support to do it is a churn generator. A self-service plan change flow lets customers upgrade, downgrade, or cancel themselves — but the engineer building it must decide between hosting it on the gateway (the Stripe Customer Portal) and building a custom flow, then handle proration preview, confirmation, and webhook synchronization so the displayed price matches what actually gets charged. This sits within Customer Portal & Self-Service, and the hardest part is not the UI — it is making the preview the customer sees identical to the invoice they receive.
The decision point arrives the moment your plan catalog has more than one tier and customers start asking to switch. Below is when to reach for the hosted portal versus a custom flow, then a complete custom implementation with a proration preview that never lies.
There is a hidden third axis behind the build-versus-buy question: how many distinct change shapes your catalog produces. A flat tier ladder — Basic, Pro, Enterprise, all monthly, all in one currency — is the easy case, and the hosted portal handles it perfectly. Complexity multiplies once you mix billing intervals (monthly versus annual), seat-based line items that need a quantity picker, add-on products that ride alongside the base plan, and currency-specific price IDs. Each combination is a separate proration path, and a customer moving from an annual seat-based plan to a monthly flat plan crosses three of those axes at once. Before you write a line of code, enumerate the transitions you actually intend to support and mark the ones you will block outright. A change_matrix table keyed on (from_price_id, to_price_id) with an allowed boolean is a cheap way to make the impossible transitions explicit rather than discovering them in production when a customer’s subscription_id lands in a state your proration math never anticipated.
Trade-offs
The choice is the hosted Stripe Customer Portal versus a custom in-app flow. The portal ships in hours and Stripe owns preview and confirmation; a custom flow gives full proration and UX control at a maintenance cost. Both still require webhook sync. The map contrasts them.
| Dimension | Stripe Customer Portal | Custom plan change flow |
|---|---|---|
| Time to ship | Hours (config + redirect) | Days to weeks |
| Proration control | Stripe defaults, limited override | Full control over credit logic |
| Preview fidelity | Stripe-computed, shown in portal | You compute; must match gateway exactly |
| UI/branding | Limited theming | Fully custom in-app |
| PCI scope | SAQ A (hosted) | SAQ A if using hosted fields |
| Webhook work | Still required for sync | Still required for sync |
| Downgrade scheduling | At period end (configurable) | Any policy you implement |
| Vendor lock-in | High (portal session API) | Low (gateway-agnostic core) |
| Maintenance burden | Stripe maintains UI | You maintain everything |
The hosted portal is the right default when branding and proration policy are not differentiators — you redirect, Stripe handles preview and confirmation, you handle webhook sync. Build custom when you need in-app UX, non-standard proration (e.g. no credit on downgrade), or multi-gateway support. Either way, webhook reconciliation is mandatory; the portal does not remove that work.
The lock-in line item nobody prices in
The maintenance-burden row in the table understates the real cost of the custom path, because the burden is not the code you write once — it is the code you keep in lockstep with the gateway forever. When Stripe changes how it prorates a mid-cycle quantity increase, or introduces a new proration_behavior mode, the portal absorbs that change silently while your custom previewProration keeps returning the old number and quietly drifts out of agreement with the invoice. That is why the honest custom architecture does not hand-roll the arithmetic at all: it calls the gateway’s upcoming-invoice endpoint to compute the preview and treats the returned figure as authoritative, keeping only the rendering, confirmation gate, and reconciliation in your own code. You still own the UX and the ledger, but you stop owning a copy of Stripe’s rounding rules that rots the moment they change. The previewProration function shown later is deliberately simple to make the mechanics legible; in production it is the fallback for a self-serve preview, not the number you bill against.
A second consideration rarely captured in a build-versus-buy table is auditability. A custom flow lets you write a plan_change_confirmed row into your own billing_events table at the exact moment the customer clicks, carrying the preview they saw and the idempotency_key they submitted under. When a customer later disputes a charge — “I never agreed to that price” — you can produce the stored preview payload alongside the resulting invoice_id. The hosted portal gives you the outcome via webhook but not the intent capture, so if disputes and chargebacks are a meaningful cost center for your business, the custom flow pays for itself in the evidence trail alone.
Step-by-Step Implementation
The five steps enforce a preview-then-confirm contract: load context, compute a preview without committing, render and require confirmation, submit idempotently, then reconcile from the webhook. The flow shows the preview/confirm gate that keeps the displayed price honest.
1. Fetch current subscription and target price
Load the active subscription and the target price, and reject the change if the subscription is not in a changeable state.
async function loadChangeContext(customerId: string, targetPriceId: string) {
const sub = await db.oneOrNone(
`SELECT subscription_id, price_id, current_period_start, current_period_end, status
FROM subscriptions WHERE customer_id = $1 AND status IN ('active','trialing')`,
[customerId]
);
if (!sub) throw new Error('no_changeable_subscription'); // ✗ reject past_due/canceled
const target = await db.one(`SELECT price_id, amount_minor, currency FROM prices WHERE price_id = $1`, [targetPriceId]);
return { sub, target };
}
The status IN ('active','trialing') guard is doing more work than it looks. A subscription in past_due is mid-recovery and must not be re-priced underneath the dunning engine; a canceled subscription has no future period to prorate against; an incomplete one has never successfully charged, so there is no established plan to move from. Rejecting with a specific no_changeable_subscription code — rather than a generic 400 — lets the frontend branch: an incomplete customer should be routed to complete their initial payment, while a past_due customer should be shown the recovery flow, not a plan picker. Loading current_period_start and current_period_end in the same query is not incidental either; those two timestamps are the denominator and numerator of every proration ratio computed downstream, and reading them once at the top of the flow avoids a subtle bug where the period rolls over between the preview call and the confirm call, producing two different ratios for what the customer experienced as a single decision.
2. Compute a proration preview without committing
Credit the unused time on the current plan and charge the prorated cost of the new plan. Work in integer minor units. For deeper proration mechanics see How to Calculate Prorated Charges for Mid-Cycle Upgrades.
function previewProration(ctx: ChangeContext, now: Date) {
const { current_period_start, current_period_end } = ctx.sub;
const total = +current_period_end - +current_period_start;
const remaining = +current_period_end - +now;
const ratio = Math.max(0, remaining / total);
const unusedCredit = Math.round(ctx.currentAmountMinor * ratio); // credit old plan
const newProrated = Math.round(ctx.target.amount_minor * ratio); // charge new plan
const netMinor = newProrated - unusedCredit; // can be negative on downgrade
return { unusedCredit, newProrated, netMinor, currency: ctx.target.currency };
}
Two details in previewProration cause most of the cent-level disagreements teams report. The first is the order of rounding: rounding unusedCredit and newProrated independently and then subtracting can differ by a cent from computing the net ratio once and rounding at the end, because two Math.round calls introduce two rounding errors that do not cancel. Match whatever the gateway does — Stripe rounds each line item, so this code rounds each line item — and never refactor it into a “cleaner” single-round version without re-verifying against a real invoice. The second is the clock. remaining / total is exquisitely sensitive to which timestamp you call now: the customer’s browser clock, your API server’s clock, and the gateway’s clock can differ by seconds, and near a period boundary a few seconds moves the ratio enough to shift the net by a cent on a large plan. Always pass a server-side now captured once, and accept that the gateway’s own clock is the final authority — which is the deeper reason step five treats the webhook, not this preview, as truth.
3. Render the preview and require explicit confirmation
Show the exact net amount and tax before the customer commits. Never auto-apply.
app.post('/portal/plan-change/preview', requireCustomer, async (req, res) => {
const ctx = await loadChangeContext(req.customerId, req.body.targetPriceId);
const p = previewProration(ctx, new Date());
const tax = await taxEngine.quote(req.customerId, p.newProrated);
res.json({
netMinor: p.netMinor, taxMinor: tax.amountMinor,
totalMinor: p.netMinor + tax.amountMinor, currency: p.currency,
requiresConfirmation: true, // ⚠️ UI must gate the submit on this
});
});
The requiresConfirmation: true flag looks like UI ceremony, but it is the seam where preview and charge can silently diverge. The preview is a snapshot taken at a specific instant; the customer may sit on the confirmation screen for minutes before clicking. A robust flow mints a short-lived preview token — an HMAC over targetPriceId, the computed netMinor, and a timestamp — and returns it alongside the figures. The confirm endpoint re-validates that token and rejects a preview older than, say, 90 seconds with a stale_preview code that prompts the frontend to silently re-fetch and re-render. Without that expiry, a customer who leaves the tab open across a period boundary confirms against a netMinor that no longer describes reality, and you are back to the preview-versus-charge mismatch you built the whole gate to prevent. Also surface tax as its own line: bundling taxMinor into a single total hides the fact that a plan change in a new billing jurisdiction can move the tax rate, and customers who see only a combined figure assume they were overcharged.
4. Submit the change with an idempotency key
The confirm call carries the idempotency key and the preview token so the server can detect a stale preview.
app.post('/portal/plan-change/confirm', requireCustomer, async (req, res) => {
const { targetPriceId, idempotencyKey } = req.body;
const inserted = await db.result(
`INSERT INTO billing_events (customer_id, type, payload, idempotency_key)
VALUES ($1, 'plan_change_confirmed', $2, $3)
ON CONFLICT (idempotency_key) DO NOTHING`,
[req.customerId, JSON.stringify({ targetPriceId }), idempotencyKey]
);
if (inserted.rowCount === 0) return res.status(200).json({ status: 'already_applied' }); // ✅
await gateway.updateSubscription({ targetPriceId, idempotencyKey, prorationBehavior: 'create_prorations' });
res.status(202).json({ status: 'processing' });
});
The ON CONFLICT (idempotency_key) DO NOTHING pattern is the load-bearing line of the confirm endpoint, and its placement matters. The insert happens before the gateway call, not after, so the database row is the mutual-exclusion lock: the first request to insert wins the right to call gateway.updateSubscription, and any concurrent duplicate — a double-click, a retried fetch, a customer refreshing the tab — hits the conflict and returns already_applied without touching the gateway. Reusing the customer-supplied idempotencyKey as the gateway’s own idempotency key closes the last gap: even if your process crashes between the insert and a successful gateway acknowledgement, the retry replays the exact same key and Stripe returns the original result rather than creating a second proration. The endpoint answers 202 processing, never 200 done, precisely because the plan is not yet applied when this handler returns — a distinction the frontend must respect by polling or waiting for a pushed update rather than optimistically redrawing the UI as if the new plan were live.
5. Reconcile final state from the webhook
The webhook — not the HTTP response — is the source of truth for the applied plan. Cancel is the same flow with cancel_at_period_end.
def on_subscription_updated(event: dict, ledger, store) -> None:
if store.exists(event["id"]):
return # ✅ idempotent
obj = event["data"]["object"]
with db.transaction():
db.execute(
"UPDATE subscriptions SET price_id=%s, status=%s WHERE subscription_id=%s",
(obj["price_id"], obj["status"], obj["subscription_id"]),
)
ledger.post_proration(obj) # double-entry from the prorated invoice
store.mark(event["id"], ttl=604800)
Verification & Testing
One reconciliation subtlety hides in the webhook handler: customer.subscription.updated fires for reasons that have nothing to do with a plan change — a card update, a metadata edit, a cancel-at-period-end toggle all emit the same event type. Blindly writing price_id from every such event is harmless when the price is unchanged, but if you also ledger.post_proration on every update you will double-post a proration the first time an unrelated event arrives after a real change. Guard the ledger posting on the presence of a proration line in the invoice referenced by the event, not on the event type alone, and let the store.exists(event["id"]) dedupe handle gateway redeliveries, which are routine and can arrive hours apart.
The headline test is preview-equals-invoice within a cent — the most common production bug. Around it: idempotent confirm, downgrade-at-period-end timing, and a ledger-versus-invoice reconciliation. The panel lists them.
Assert that the preview net amount equals the gateway’s actual prorated invoice within a one-cent tolerance — drift here is the most common production bug. Submit the confirm endpoint twice with the same idempotency key and assert exactly one subscription update and one ledger posting. Use a mock clock to verify a downgrade scheduled at period end does not change access until the boundary, while an upgrade applies immediately. Reconcile with this query to catch any plan change whose ledger net does not match its invoice:
SELECT s.subscription_id, i.amount_minor AS invoiced, l.net_minor AS ledgered
FROM subscriptions s
JOIN invoices i ON i.subscription_id = s.subscription_id
JOIN ledger_prorations l ON l.invoice_id = i.invoice_id
WHERE i.amount_minor <> l.net_minor; -- expect zero rows
Test the transitions, not just the happy path
A preview-equals-invoice assertion on a single Basic-to-Pro upgrade proves almost nothing, because the bugs live in the transitions you tested least. Build a parameterized suite that walks the change_matrix: every allowed (from_price_id, to_price_id) pair, run against a recorded gateway fixture, asserting the preview net matches the fixture invoice within one cent. Include the awkward pairs explicitly — an upgrade one hour after the period starts (ratio near 1.0, nearly the full new-plan charge), a downgrade one hour before it ends (ratio near 0.0, a near-zero credit), and a same-day up-then-down that should net close to zero. These edge ratios are where independent rounding of unusedCredit and newProrated produces its worst disagreements, and they are exactly the cases a hand-written happy-path test skips.
Two failure modes deserve dedicated tests beyond the arithmetic. First, replay: capture a real customer.subscription.updated payload and feed it to on_subscription_updated twice, asserting one row update and one ledger posting, then feed a second unrelated update for the same subscription_id and assert the ledger does not move. Second, the stale-preview path: advance a mock clock past the preview token’s expiry between the preview and confirm calls and assert the confirm endpoint returns stale_preview rather than charging against the outdated netMinor. A flow that passes both of these plus the matrix walk has covered the mismatch, the double-apply, and the boundary-timing bugs that account for nearly every plan-change support ticket.
Gotchas & Production Pitfalls
The pitfalls are preview/charge mismatch, mishandled downgrade credits, dunning collisions, trusting the HTTP response, and immediate cancel access loss. The map groups them so each fix is one rule.
- Preview/charge mismatch. Computing proration with a different rounding rule or clock than the gateway means the customer sees one number and pays another. Either compute exactly as the gateway does, or fetch the gateway’s own preview (Stripe’s upcoming-invoice endpoint) and display that.
- Downgrade credits as negative invoices. A downgrade often yields a credit, not a charge. Decide upfront whether to refund, issue account credit, or carry the balance forward — and never let a negative
netMinorsilently become a zero charge. - Race with active dunning. If the subscription is
past_dueand a retry is in flight, a plan change can collide with the recovery engine. Block changes on non-active states or coordinate with Grace Period & Retry Logic. - Trusting the HTTP response. The synchronous confirm response can return before the gateway finalizes. Always reconcile the final plan from the
customer.subscription.updatedwebhook, not the API call. - Cancel that revokes access immediately. Customers expect to keep access through the period they paid for. Use
cancel_at_period_endand revoke at the boundary, not on click.
Frequently Asked Questions
Should a plan change take effect immediately or at renewal? Upgrades almost always immediately, because the customer is asking for more and expects it now. Downgrades usually at renewal, because the current period is already paid for and immediate downgrades require a credit path most products do not need.
How do I stop a customer from switching plans repeatedly to game proration? Rate-limit changes per billing period and make the proration rule symmetric, so a rapid up-then-down cycle nets to roughly zero rather than generating a credit. A simple limit of one or two changes per period removes the incentive without affecting legitimate use.
What should the confirmation screen show? The new price, the exact amount charged or credited today, the next billing date, and the resulting feature changes. Showing only the new monthly price and charging a prorated amount produces a support contact almost every time.
Do plan changes need their own audit trail? Yes. Store who changed what, when, and from which plan to which, because “I never chose that plan” is a common dispute and the subscription record alone only shows the current state.