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