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.

Trade-offs

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.

Step-by-Step Implementation

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 };
}

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 };
}

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
  });
});

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' });
});

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

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

Gotchas & Production Pitfalls

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