From e5d25e9b794bf7d96faf3b85e480799f63f24338 Mon Sep 17 00:00:00 2001 From: ClaydeCode Date: Fri, 5 Jun 2026 14:27:40 +0000 Subject: [PATCH] feat(resize): authorize resize via in-window PayPal SDK revise The resize popup opened PayPal's hosted approval page and relied on a return_url redirect, which 404s on the shard nginx and never closed the popup. Switch subscribed resizes to the same in-window JS SDK flow as subscribe: render a PayPal button whose createSubscription calls actions.subscription.revise(); onApprove polls until the webhook commits the change; onCancel/onError release the pending slot via /resize/cancel. Unsubscribed shards keep the immediate resize path. The hasPendingResize watcher clears the busy state once the webhook flips pending off. Depends on controller endpoints returning {subscription_id, plan_id, expected_price_cents} from /resize and the new /resize/cancel. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/views/Settings.vue | 131 ++++++++++++++++++++++++++++++----------- 1 file changed, 96 insertions(+), 35 deletions(-) diff --git a/src/views/Settings.vue b/src/views/Settings.vue index 81f7cef..4080a33 100644 --- a/src/views/Settings.vue +++ b/src/views/Settings.vue @@ -245,16 +245,17 @@ - - - - Resize to {{ resize.selectedSize | uppercase }} and restart - - - +
+ + Approve the new price to resize to {{ resize.selectedSize | uppercase }}. The shard restarts afterwards. + + +
+ Cancel - +
A size change is already pending; new resizes are disabled until it completes. @@ -354,6 +355,7 @@ export default { }, subscribing: false, paypalButtonRendered: false, + resizeButtonInstance: null, cancelAlert: false, pollingInterval: null, pollingTimeoutHandle: null, @@ -406,9 +408,28 @@ export default { const sub = this.$store.state.profile && this.$store.state.profile.subscription; return !!(sub && sub.pending_vm_size); }, + resizeNeedsApproval() { + const p = this.$store.state.profile; + return !!(p && p.billing_enabled && p.subscription && p.subscription.status === 'active'); + }, }, watch: { + 'resize.selectedSize'(newSize) { + if (newSize && this.resizeNeedsApproval) { + this.$nextTick(() => this.renderResizeButton()); + } else { + this.teardownResizeButton(); + } + }, + hasPendingResize(now, was) { + // pending cleared by the UPDATED webhook → the resize is committed. + if (was && !now) { + this.resize.waitingForRestart = false; + this.resize.selectedSize = null; + this.stopInterstitialPolling(); + } + }, '$store.state.profile.subscription': { handler(newVal) { if (newVal && newVal.status === 'active') { @@ -516,41 +537,80 @@ export default { } }, async resizeShard() { + // Immediate resize path for unsubscribed shards (no price approval needed). + // Subscribed shards go through the PayPal SDK revise button instead. this.resize.waitingForRestart = true; - // Set when a PayPal popup is in flight: the popup-close watcher owns - // resetting waitingForRestart, so the finally block must not clear it. - let popupInFlight = false; try { - const response = await this.$http.post( + await this.$http.post( '/core/protected/management/api/shards/self/resize', {new_vm_size: this.resize.selectedSize}); - if (response.data && response.data.approval_url) { - // The POST already happened, so we cannot open the popup synchronously - // on the click; if the browser blocks it, fall back to a redirect. - const popup = window.open(response.data.approval_url, 'paypal-revise', 'width=500,height=700'); - if (!popup) { - window.location = response.data.approval_url; - return; - } - popupInFlight = true; - const timer = setInterval(async () => { - if (popup.closed) { - clearInterval(timer); - await this.$store.dispatch('force_query_profile_data').catch(() => {}); - this.resize.waitingForRestart = false; - } - }, 800); - return; - } await this.$router.replace('/restart'); } catch (e) { - this.toastError('Error during resize', e.response.data.detail); - await this.$store.dispatch('force_query_profile_data').catch(() => {}); + this.toastError('Error during resize', (e.response && e.response.data && e.response.data.detail) || e.message); } finally { - if (!popupInFlight) { - this.resize.waitingForRestart = false; - } + this.resize.waitingForRestart = false; + } + }, + cancelResizeSelection() { + // Watcher tears down the PayPal button when selectedSize clears. + this.resize.selectedSize = null; + }, + teardownResizeButton() { + if (this.resizeButtonInstance) { + try { + this.resizeButtonInstance.close(); + } catch (e) { /* button already gone */ } + this.resizeButtonInstance = null; } + const container = this.$refs.resizeButton; + if (container) container.innerHTML = ''; + }, + async renderResizeButton() { + const profile = this.$store.state.profile; + if (!this.resize.selectedSize || !this.resizeNeedsApproval) return; + if (!this.$refs.resizeButton) return; + this.teardownResizeButton(); + try { + await loadPaypalSdk(profile.paypal_client_id); + } catch (e) { + this.toastError('PayPal error', 'Could not load PayPal.'); + return; + } + // State may have changed while the SDK loaded. + if (!this.$refs.resizeButton || !this.resize.selectedSize || !this.resizeNeedsApproval) return; + const selectedSize = this.resize.selectedSize; + const cancelPending = () => + this.$http.post('/core/protected/management/api/shards/self/resize/cancel').catch(() => {}); + this.resizeButtonInstance = window.paypal.Buttons({ + createSubscription: async (data, actions) => { + // Claim the pending-resize slot and get the new quantity, then revise + // the existing subscription in-window. + const {data: r} = await this.$http.post( + '/core/protected/management/api/shards/self/resize', + {new_vm_size: selectedSize}); + // NOTE: verify this actions.subscription.revise signature in the PayPal sandbox. + return actions.subscription.revise(r.subscription_id, { + plan_id: r.plan_id, + quantity: String(r.expected_price_cents), + }); + }, + onApprove: async () => { + // The UPDATED webhook promotes the price, clears pending and resizes + // the VM; poll until the profile reflects it (hasPendingResize watcher + // then clears waitingForRestart). + this.resize.waitingForRestart = true; + this.startInterstitialPolling(); + await this.$store.dispatch('force_query_profile_data').catch(() => {}); + }, + onCancel: async () => { + await cancelPending(); + }, + onError: async (err) => { + await cancelPending(); + this.toastError('PayPal error', String(err)); + }, + }); + this.resizeButtonInstance.render(this.$refs.resizeButton); }, async renderPaypalButton() { const profile = this.$store.state.profile; @@ -654,6 +714,7 @@ export default { beforeDestroy() { this.stopInterstitialPolling(); + this.teardownResizeButton(); }, }