From 9fd320832fe6cb7149758369564a6ea96c569022 Mon Sep 17 00:00:00 2001 From: GTC6244 <95836911+GTC6244@users.noreply.github.com> Date: Thu, 23 Apr 2026 22:33:40 -0400 Subject: [PATCH] feat: add crypto payment options to dashboard Stripe modal (CPL-274) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Switch the Add Funds modal from Stripe Card Element to Payment Element, which auto-renders any payment methods enabled on the Stripe account (card, USDC, USDP, ETH, SOL). Backend billing endpoints already support crypto — this is frontend-only. Flow: user picks amount → Continue creates a PaymentIntent and mounts the Payment Element with its client_secret → Pay calls stripe.confirmPayment with redirect: 'if_required'. Card stays inline; crypto redirects to the wallet flow and returns to the dashboard with ?payment_intent=...&redirect_status=succeeded, which handleBillingReturn detects on page load to call confirm_payment and refresh the balance. Co-Authored-By: Claude Opus 4.7 --- lit-static/dapps/dashboard/app.js | 3 +- lit-static/dapps/dashboard/billing.js | 295 ++++++++++++++++++-------- lit-static/dapps/dashboard/index.html | 14 +- 3 files changed, 214 insertions(+), 98 deletions(-) diff --git a/lit-static/dapps/dashboard/app.js b/lit-static/dapps/dashboard/app.js index 20d1c5d0..837b52cd 100644 --- a/lit-static/dapps/dashboard/app.js +++ b/lit-static/dapps/dashboard/app.js @@ -5,7 +5,7 @@ import { getApiKey, setTheme, getTheme, setApiKey, setOnAuthReady, updateStatCards, initLogin, setUsageKeyOverride, toggleOverrideEnabled, updateUsageKeyOverrideUI, clearOverrideState } from './auth.js'; import { initModalClose, initConfirmClose, showStatus, hideStatus, logError } from './ui-utils.js'; -import { initBilling } from './billing.js'; +import { initBilling, handleBillingReturn } from './billing.js'; import { initGroups, loadGroups } from './groups.js'; import { initKeys, loadUsageKeys } from './keys.js'; import { initActions, loadActions } from './actions.js'; @@ -154,6 +154,7 @@ setOnAuthReady(() => { updateStatCards(); preloadAllTables(); updateUsageKeyOverrideUI(); + handleBillingReturn(); }); // ----- Init ----- diff --git a/lit-static/dapps/dashboard/billing.js b/lit-static/dapps/dashboard/billing.js index a219fa99..3ba93166 100644 --- a/lit-static/dapps/dashboard/billing.js +++ b/lit-static/dapps/dashboard/billing.js @@ -1,12 +1,20 @@ /** * Billing — Stripe integration, payment flow. + * + * Uses Stripe Payment Element which auto-renders whichever methods + * are enabled on the account (card, USDC, USDP, ETH, SOL). Crypto + * payments use a redirect flow; card payments complete inline when + * no additional action is required. */ import { getApiKey, getClient, hasUsageKeyOverride } from './auth.js'; import { formatError, logError } from './ui-utils.js'; let _stripe = null; -let _stripeCard = null; +let _publishableKey = null; +let _elements = null; +let _paymentElement = null; +let _paymentIntentId = null; let _billingAvailable = null; let _billingCheckedAt = 0; @@ -20,7 +28,8 @@ export async function checkBillingAvailable() { } try { const client = await getClient(); - await client.getStripeConfig(); + const cfg = await client.getStripeConfig(); + _publishableKey = cfg.publishable_key; _billingAvailable = true; } catch (_) { _billingAvailable = false; @@ -94,6 +103,61 @@ async function loadBillingBalance() { } } +function setModalStep(step) { + const amountGroup = document.getElementById('billing-amount-group'); + const paymentGroup = document.getElementById('billing-payment-group'); + const continueBtn = document.getElementById('billing-continue-btn'); + const payBtn = document.getElementById('billing-pay-btn'); + const backBtn = document.getElementById('billing-back-btn'); + if (step === 'amount') { + if (amountGroup) amountGroup.style.display = ''; + if (paymentGroup) paymentGroup.style.display = 'none'; + if (continueBtn) continueBtn.style.display = ''; + if (payBtn) payBtn.style.display = 'none'; + if (backBtn) backBtn.style.display = 'none'; + } else { + if (amountGroup) amountGroup.style.display = 'none'; + if (paymentGroup) paymentGroup.style.display = ''; + if (continueBtn) continueBtn.style.display = 'none'; + if (payBtn) payBtn.style.display = ''; + if (backBtn) backBtn.style.display = ''; + } +} + +function setStatus(message, kind) { + const el = document.getElementById('billing-modal-status'); + if (!el) return; + if (!message) { + el.style.display = 'none'; + el.textContent = ''; + return; + } + el.textContent = message; + el.className = 'status ' + (kind || 'info'); + el.style.display = 'block'; +} + +function resetPaymentElement() { + if (_paymentElement) { + try { _paymentElement.unmount(); } catch (_) { /* ignore */ } + try { _paymentElement.destroy(); } catch (_) { /* ignore */ } + } + _paymentElement = null; + _elements = null; + _paymentIntentId = null; +} + +async function ensureStripe() { + if (_stripe) return _stripe; + if (!_publishableKey) { + const client = await getClient(); + const cfg = await client.getStripeConfig(); + _publishableKey = cfg.publishable_key; + } + _stripe = Stripe(_publishableKey); // eslint-disable-line no-undef + return _stripe; +} + async function openAddFundsModal() { if (_billingAvailable === false) return; const overlay = document.getElementById('billing-modal-overlay'); @@ -101,46 +165,159 @@ async function openAddFundsModal() { overlay.classList.add('is-open'); overlay.setAttribute('aria-hidden', 'false'); - const statusEl = document.getElementById('billing-modal-status'); - if (statusEl) { statusEl.style.display = 'none'; } + setStatus(''); + setModalStep('amount'); + resetPaymentElement(); + + try { + await ensureStripe(); + } catch (e) { + logError('stripe-init', e); + setStatus('Billing not available: ' + formatError(e), 'error'); + } +} + +function closeBillingModal() { + const overlay = document.getElementById('billing-modal-overlay'); + if (overlay) { + overlay.classList.remove('is-open'); + overlay.setAttribute('aria-hidden', 'true'); + } + resetPaymentElement(); + setModalStep('amount'); + setStatus(''); +} + +async function handleContinue() { + const apiKey = getApiKey(); + if (!apiKey) return; + + const amountInput = document.getElementById('billing-amount'); + const amountCents = parseInt(amountInput?.value || '0', 10); + if (!amountCents || amountCents < 500) { + setStatus('Minimum amount is $5.00.', 'error'); + return; + } + + const continueBtn = document.getElementById('billing-continue-btn'); + if (continueBtn) continueBtn.disabled = true; + setStatus(''); + + try { + await ensureStripe(); + const client = await getClient(); + const intent = await client.createPaymentIntent(apiKey, amountCents); + _paymentIntentId = intent.payment_intent_id; + + _elements = _stripe.elements({ clientSecret: intent.client_secret }); + _paymentElement = _elements.create('payment'); + _paymentElement.mount('#stripe-payment-element'); + setModalStep('payment'); + } catch (e) { + logError('createPaymentIntent', e); + setStatus('Could not start payment: ' + formatError(e), 'error'); + } finally { + if (continueBtn) continueBtn.disabled = false; + } +} + +function handleBack() { + resetPaymentElement(); + setStatus(''); + setModalStep('amount'); +} + +async function handlePay() { + const apiKey = getApiKey(); + if (!apiKey || !_stripe || !_elements || !_paymentIntentId) return; const payBtn = document.getElementById('billing-pay-btn'); + const backBtn = document.getElementById('billing-back-btn'); if (payBtn) payBtn.disabled = true; + if (backBtn) backBtn.disabled = true; + setStatus(''); + + const intentId = _paymentIntentId; + // Stripe redirects to return_url for methods that require it (crypto); + // card payments complete inline when redirect: 'if_required' is set. + const returnUrl = window.location.origin + window.location.pathname; + + try { + const result = await _stripe.confirmPayment({ + elements: _elements, + confirmParams: { return_url: returnUrl }, + redirect: 'if_required', + }); + + if (result.error) { + throw new Error(result.error.message); + } - if (!_stripe) { try { const client = await getClient(); - const cfg = await client.getStripeConfig(); - _stripe = Stripe(cfg.publishable_key); // eslint-disable-line no-undef - const elements = _stripe.elements(); - _stripeCard = elements.create('card'); - _stripeCard.mount('#stripe-card-element'); - } catch (e) { - logError('stripe-init', e); - if (statusEl) { - statusEl.textContent = 'Billing not available: ' + formatError(e); - statusEl.className = 'status error'; - statusEl.style.display = 'block'; - } + await client.confirmPayment(apiKey, intentId); + } catch (confirmErr) { + logError('confirmPayment', confirmErr, { intentId }); + setStatus('Payment processed — credit pending. Reference: ' + intentId, 'info'); + closeBillingModal(); + await loadBillingBalance(); return; } - } - if (payBtn) payBtn.disabled = false; + closeBillingModal(); + await loadBillingBalance(); + } catch (e) { + logError('payment', e, { intentId }); + setStatus('Payment failed: ' + formatError(e), 'error'); + } finally { + if (payBtn) payBtn.disabled = false; + if (backBtn) backBtn.disabled = false; + } } -function closeBillingModal() { - const overlay = document.getElementById('billing-modal-overlay'); - if (overlay) { - overlay.classList.remove('is-open'); - overlay.setAttribute('aria-hidden', 'true'); +export async function handleBillingReturn() { + const params = new URLSearchParams(window.location.search); + const intentId = params.get('payment_intent'); + const status = params.get('redirect_status'); + if (!intentId || !status) return; + + // Strip Stripe redirect params regardless of outcome so reloads don't retrigger. + const cleanUrl = window.location.origin + window.location.pathname + window.location.hash; + window.history.replaceState({}, '', cleanUrl); + + const apiKey = getApiKey(); + if (!apiKey) return; + + if (status !== 'succeeded') { + showTopLevelStatus('Payment ' + status + '. Reference: ' + intentId, 'error'); + return; } + + try { + const client = await getClient(); + await client.confirmPayment(apiKey, intentId); + showTopLevelStatus('Credits added to your account.', 'success'); + await loadBillingBalance(); + } catch (e) { + logError('handleBillingReturn', e, { intentId }); + showTopLevelStatus('Payment processed — credit pending. Reference: ' + intentId, 'info'); + } +} + +function showTopLevelStatus(message, kind) { + const el = document.getElementById('overview-status'); + if (!el) return; + el.textContent = message; + el.className = 'status ' + (kind || 'info'); + el.style.display = 'block'; } export function initBilling() { const addFundsBtn = document.getElementById('btn-add-funds'); const closeBtn = document.getElementById('billing-modal-close-btn'); const cancelBtn = document.getElementById('billing-cancel-btn'); + const continueBtn = document.getElementById('billing-continue-btn'); + const backBtn = document.getElementById('billing-back-btn'); const payBtn = document.getElementById('billing-pay-btn'); if (addFundsBtn) addFundsBtn.addEventListener('click', openAddFundsModal); @@ -148,71 +325,7 @@ export function initBilling() { if (noFundsLink) noFundsLink.addEventListener('click', (e) => { e.preventDefault(); openAddFundsModal(); }); if (closeBtn) closeBtn.addEventListener('click', closeBillingModal); if (cancelBtn) cancelBtn.addEventListener('click', closeBillingModal); - - if (payBtn) { - payBtn.addEventListener('click', async () => { - const apiKey = getApiKey(); - if (!apiKey || !_stripe || !_stripeCard) return; - - const amountInput = document.getElementById('billing-amount'); - const amountCents = parseInt(amountInput.value, 10); - const statusEl = document.getElementById('billing-modal-status'); - - // Client-side validation: minimum $5.00 - if (!amountCents || amountCents < 500) { - if (statusEl) { - statusEl.textContent = 'Minimum amount is $5.00.'; - statusEl.className = 'status error'; - statusEl.style.display = 'block'; - } - return; - } - - payBtn.disabled = true; - if (statusEl) { statusEl.style.display = 'none'; } - - let intentId = null; - try { - const client = await getClient(); - const intent = await client.createPaymentIntent(apiKey, amountCents); - intentId = intent.payment_intent_id; - - const result = await _stripe.confirmCardPayment(intent.client_secret, { - payment_method: { card: _stripeCard }, - }); - - if (result.error) { - throw new Error(result.error.message); - } - - // Separate try for confirmPayment — card is already charged at this point - try { - await client.confirmPayment(apiKey, intent.payment_intent_id); - } catch (confirmErr) { - logError('confirmPayment', confirmErr, { intentId }); - if (statusEl) { - statusEl.textContent = 'Payment processed — credit pending. Reference: ' + intent.payment_intent_id; - statusEl.className = 'status info'; - statusEl.style.display = 'block'; - } - closeBillingModal(); - await loadBillingBalance(); - return; - } - - closeBillingModal(); - await loadBillingBalance(); - } catch (e) { - logError('payment', e, { intentId }); - if (statusEl) { - statusEl.textContent = 'Payment failed: ' + formatError(e); - statusEl.className = 'status error'; - statusEl.style.display = 'block'; - } - } finally { - payBtn.disabled = false; - } - }); - } + if (continueBtn) continueBtn.addEventListener('click', handleContinue); + if (backBtn) backBtn.addEventListener('click', handleBack); + if (payBtn) payBtn.addEventListener('click', handlePay); } - diff --git a/lit-static/dapps/dashboard/index.html b/lit-static/dapps/dashboard/index.html index 6013a70b..30be1c96 100644 --- a/lit-static/dapps/dashboard/index.html +++ b/lit-static/dapps/dashboard/index.html @@ -408,8 +408,8 @@