mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-11 21:39:01 +00:00
feat(billing): confirm-upgrade polling on /console/billing (TASK-712) (#231)
* feat(billing): confirm-upgrade polling on /console/billing (TASK-712) Stripe Checkout redirects back to /console/billing?checkout=success the moment the user finishes paying, but pad-cloud's checkout.session.completed webhook is asynchronous — it needs a beat to land, authenticate against pad's /admin/plan endpoint, and flip the user's plan to "pro". Before this change, the returning user saw the Free plan with the "Upgrade to Pro" button and had to refresh manually before the app caught up. Changes on /console/billing: - Detects ?checkout=success on mount. Runs a single fresh authStore.load() first — if the webhook is already in, skip straight to the confirmed state. Otherwise start polling authStore.load() every 2s for up to 30s. - Four states: idle (default), checking (spinner + "Confirming your upgrade…"), confirmed (green check + "welcome to Pro!"), timeout (yellow, payment went through + support contact). - On confirm, clears the ?checkout=success query via history.replaceState so a page reload does not re-enter the polling branch. - onDestroy stops the interval — no dangling timers after navigation. - Reduced-motion users see a static spinner frame per prefers-reduced-motion. - Banner has role="status" aria-live="polite" so screen readers announce state changes. Reuses authStore's existing inflight-coalescing + generation guard (shipped with PR #229), so concurrent polls share a single /auth/session fetch and a post-logout navigation cannot resurrect a stale plan value. Parent: PLAN-645 (Pad Cloud Beta Readiness). TASK-712 bullet 2. Bullet 3 (failed-payment email) ships next; bullet 4 (plan matrix) later. * fix(billing): destroyed guard, parallel tasks, plan-reconcile banner (Codex round 1) Addresses PR #231 review findings: HIGH — onMount's awaits could race with onDestroy: a late authStore.load() or plan-limits fetch finishing after the user navigated away would still mutate upgradeStatus/limits, and startUpgradeConfirmation could even install a setInterval on a destroyed component. Added a 'destroyed' flag set in onDestroy and checked after every await; stopPolling also runs on teardown and inside pollForUpgrade's post-await guard for belt-and- braces. MEDIUM — startUpgradeConfirmation was sequenced behind the plan-limits fetch. A slow /plan-limits request would delay the 'checking' banner and the first authStore.load() refresh, defeating the purpose of the PR. Split them: onMount is now synchronous, kicks off startUpgradeConfirmation and loadPlanLimits in parallel as fire-and-forget promises, each with its own destroyed-guarded error handling. LOW — upgradeStatus latched 'confirmed' independently of the current plan value. If plan transitioned away from 'pro' for any reason after the banner appeared, it would stay stuck showing the success message. Render the confirmed banner only while upgradeStatus === 'confirmed' AND isPro so the banner fades out automatically if the plan reconciles down.
This commit is contained in:
@@ -1,7 +1,8 @@
|
||||
<script lang="ts">
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { onMount } from 'svelte';
|
||||
import { page } from '$app/state';
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
|
||||
interface PlanLimits {
|
||||
workspaces: number;
|
||||
@@ -15,24 +16,122 @@
|
||||
let isPro = $derived(plan === 'pro');
|
||||
let limits = $state<{ free: PlanLimits; pro: PlanLimits } | null>(null);
|
||||
|
||||
// Upgrade-confirmation state. After Stripe Checkout redirects back with
|
||||
// ?checkout=success, pad-cloud's webhook handler needs a moment to land
|
||||
// at pad's /admin/plan endpoint before the user's plan flips to "pro".
|
||||
// Poll authStore.load() on a short interval until the plan updates or we
|
||||
// hit the timeout — a proxy for "something is wrong, please refresh".
|
||||
let upgradeStatus = $state<'idle' | 'checking' | 'confirmed' | 'timeout'>('idle');
|
||||
let pollTimer: ReturnType<typeof setInterval> | null = null;
|
||||
let pollStartedAt = 0;
|
||||
// Guards every post-await state write: an authStore.load() or plan-limits
|
||||
// fetch in flight when the user navigates away would otherwise continue
|
||||
// on, mutate state, or even install a fresh interval after this component
|
||||
// has been destroyed. Checked after every await.
|
||||
let destroyed = false;
|
||||
const POLL_INTERVAL_MS = 2000;
|
||||
const POLL_TIMEOUT_MS = 30000;
|
||||
|
||||
function formatLimit(value: number | undefined): string {
|
||||
if (value === undefined) return '...';
|
||||
if (value === -1) return 'Unlimited';
|
||||
return value.toLocaleString();
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
function stopPolling() {
|
||||
if (pollTimer !== null) {
|
||||
clearInterval(pollTimer);
|
||||
pollTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Remove ?checkout=success so a page reload after the banner is dismissed
|
||||
// does not re-enter the polling branch. replaceState keeps the back button
|
||||
// pointing at the pre-checkout page rather than an artifact URL.
|
||||
function clearCheckoutQuery() {
|
||||
if (typeof window === 'undefined') return;
|
||||
const url = new URL(window.location.href);
|
||||
if (url.searchParams.has('checkout')) {
|
||||
url.searchParams.delete('checkout');
|
||||
history.replaceState(history.state, '', url.toString());
|
||||
}
|
||||
}
|
||||
|
||||
async function pollForUpgrade() {
|
||||
try {
|
||||
await authStore.load();
|
||||
} catch {
|
||||
// Session fetch failed this tick; another poll will retry. No state change.
|
||||
}
|
||||
if (destroyed) {
|
||||
stopPolling();
|
||||
return;
|
||||
}
|
||||
if (authStore.user?.plan === 'pro') {
|
||||
upgradeStatus = 'confirmed';
|
||||
stopPolling();
|
||||
clearCheckoutQuery();
|
||||
return;
|
||||
}
|
||||
if (Date.now() - pollStartedAt >= POLL_TIMEOUT_MS) {
|
||||
upgradeStatus = 'timeout';
|
||||
stopPolling();
|
||||
}
|
||||
}
|
||||
|
||||
// Bridge the async window between Stripe redirect and pad-cloud webhook.
|
||||
// Kicked off from onMount in parallel with plan-limits fetch so a slow
|
||||
// /plan-limits request does not delay the user's confirmation banner.
|
||||
async function startUpgradeConfirmation() {
|
||||
try {
|
||||
await authStore.load();
|
||||
} catch {
|
||||
/* polling branch below retries */
|
||||
}
|
||||
if (destroyed) return;
|
||||
if (authStore.user?.plan === 'pro') {
|
||||
upgradeStatus = 'confirmed';
|
||||
clearCheckoutQuery();
|
||||
return;
|
||||
}
|
||||
upgradeStatus = 'checking';
|
||||
pollStartedAt = Date.now();
|
||||
pollTimer = setInterval(pollForUpgrade, POLL_INTERVAL_MS);
|
||||
}
|
||||
|
||||
async function loadPlanLimits() {
|
||||
try {
|
||||
const resp = await fetch('/api/v1/plan-limits', { credentials: 'same-origin' });
|
||||
if (destroyed) return;
|
||||
if (resp.ok) {
|
||||
const body = await resp.json();
|
||||
if (destroyed) return;
|
||||
limits = body;
|
||||
}
|
||||
} catch {
|
||||
/* use fallback rendering */
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
if (!authStore.cloudMode) {
|
||||
goto('/console', { replaceState: true });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const resp = await fetch('/api/v1/plan-limits', { credentials: 'same-origin' });
|
||||
if (resp.ok) limits = await resp.json();
|
||||
} catch {
|
||||
/* use fallback rendering */
|
||||
// Kick off the two independent async tasks in parallel. Neither is
|
||||
// awaited here — onMount returns immediately so Svelte can run the
|
||||
// onDestroy cleanup path synchronously if the user navigates away
|
||||
// before either task resolves (the `destroyed` guard handles that).
|
||||
if (page.url.searchParams.get('checkout') === 'success') {
|
||||
startUpgradeConfirmation();
|
||||
}
|
||||
loadPlanLimits();
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
destroyed = true;
|
||||
stopPolling();
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -43,6 +142,22 @@
|
||||
<div class="billing-page">
|
||||
<h1 class="page-title">Billing</h1>
|
||||
|
||||
{#if upgradeStatus === 'checking'}
|
||||
<div class="upgrade-banner checking" role="status" aria-live="polite">
|
||||
<span class="spinner" aria-hidden="true"></span>
|
||||
<span>Confirming your upgrade…</span>
|
||||
</div>
|
||||
{:else if upgradeStatus === 'confirmed' && isPro}
|
||||
<div class="upgrade-banner success" role="status" aria-live="polite">
|
||||
<span class="check-icon" aria-hidden="true">✓</span>
|
||||
<span>Upgrade confirmed — welcome to Pro!</span>
|
||||
</div>
|
||||
{:else if upgradeStatus === 'timeout'}
|
||||
<div class="upgrade-banner warning" role="status" aria-live="polite">
|
||||
<span>Your payment went through but we haven't confirmed your upgrade yet. Try refreshing in a moment; if your plan still shows Free, contact <a href="mailto:support@getpad.dev">support@getpad.dev</a>.</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<section class="card">
|
||||
<h2 class="card-title">Current Plan</h2>
|
||||
<div class="card-body">
|
||||
@@ -232,4 +347,73 @@
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.upgrade-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-3) var(--space-4);
|
||||
border-radius: var(--radius);
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.4;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.upgrade-banner.checking {
|
||||
background: color-mix(in srgb, var(--accent-blue) 10%, transparent);
|
||||
border-color: color-mix(in srgb, var(--accent-blue) 30%, transparent);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.upgrade-banner.success {
|
||||
background: color-mix(in srgb, var(--accent-green) 12%, transparent);
|
||||
border-color: color-mix(in srgb, var(--accent-green) 35%, transparent);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.upgrade-banner.warning {
|
||||
background: color-mix(in srgb, var(--accent-yellow, #eab308) 12%, transparent);
|
||||
border-color: color-mix(in srgb, var(--accent-yellow, #eab308) 35%, transparent);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.upgrade-banner a {
|
||||
color: var(--accent-blue);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border: 2px solid color-mix(in srgb, var(--accent-blue) 30%, transparent);
|
||||
border-top-color: var(--accent-blue);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.check-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent-green);
|
||||
color: #fff;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.spinner {
|
||||
animation: none;
|
||||
border-top-color: transparent;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user