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.

Plan change build-vs-buy The Stripe Customer Portal ships fast with Stripe-computed preview but high lock-in; a custom flow gives full proration and UX control at a maintenance cost. Stripe Portal hours to ship Stripe preview + confirm limited theming high lock-in · webhook still required Custom flow days to weeks full proration control in-app UX low lock-in · you maintain it
The portal is the default unless branding or non-standard proration is a differentiator — webhook sync is mandatory either way.
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.

Plan change flow Load context, compute a non-committing preview, require explicit confirmation, submit with an idempotency key, and reconcile the applied plan from the webhook. 1 Load context 2 Preview no commit 3 Confirm explicit 4 Submit idem key 5 Reconcile webhook
Preview-then-confirm with idempotent submit and webhook reconciliation — the preview must match the invoice to the cent.

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.

Plan change tests Preview equals the invoice within a cent, a double confirm applies once, a downgrade takes effect at period end, and the ledger net matches the invoice. Preview = invoice within 1¢ headline test Idempotent double confirm applies once Downgrade at period end access held Reconcile ledger vs invoice zero drift
Preview-equals-invoice is the test that matters — a mismatch means the customer sees one number and pays another.

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.

Plan change pitfalls Preview mismatch, downgrade credit handling, dunning collision, trusting the HTTP response, and immediate cancel access loss are the recurring pitfalls. Mismatch preview ≠ charge → use gateway preview Downgrade negative net → decide credit policy Dunning race past_due change → block on non-active HTTP trust returns early → webhook truth Cancel revoke on click → at period end
Five pitfalls — the preview/charge mismatch and immediate-cancel access loss are the two customers notice instantly.
  • 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 netMinor silently become a zero charge.
  • Race with active dunning. If the subscription is past_due and 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.updated webhook, not the API call.
  • Cancel that revokes access immediately. Customers expect to keep access through the period they paid for. Use cancel_at_period_end and 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.