Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion lit-static/dapps/dashboard/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

import { isAuthenticated, setTheme, getTheme, logOut, setOnAuthReady, updateStatCards, initLogin, setUsageKeyOverride, toggleOverrideEnabled, updateUsageKeyOverrideUI, setChainSecuredRpcUrl, toggleChainSecuredRpcPanel, updateChainSecuredRpcUrlUI } 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';
Expand Down Expand Up @@ -237,6 +237,7 @@ setOnAuthReady(() => {
preloadAllTables();
updateUsageKeyOverrideUI();
updateChainSecuredRpcUrlUI();
handleBillingReturn();
});

// ----- Init -----
Expand Down
295 changes: 204 additions & 91 deletions lit-static/dapps/dashboard/billing.js
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -94,125 +103,229 @@ 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');
if (!overlay) return;
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();
Comment on lines +261 to +262
Copy link

Copilot AI Apr 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the confirmPayment failure fallback, setStatus('Payment processed — credit pending…') is immediately cleared because closeBillingModal() calls setStatus(''). This means the user never sees the pending/receipt message. Consider either (a) showing this message via the top-level status banner before closing, or (b) not clearing status when closing (or only clearing when opening).

Suggested change
setStatus('Payment processed — credit pending. Reference: ' + intentId, 'info');
closeBillingModal();
closeBillingModal();
setStatus('Payment processed — credit pending. Reference: ' + intentId, 'info');

Copilot uses AI. Check for mistakes.
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 });
Comment on lines +291 to +302
Copy link

Copilot AI Apr 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

redirect_status can be values like processing, failed, or canceled (not just succeeded). Treating every non-succeeded status as an error will mislabel processing payments and prevents running the backend confirmPayment path that could transition credits to pending/success. Handle processing as an info/pending state (and optionally still call confirmPayment), and only mark failed/canceled as error.

Suggested change
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 });
if (status === 'failed' || status === 'canceled') {
showTopLevelStatus('Payment ' + status + '. Reference: ' + intentId, 'error');
return;
}
if (status === 'processing') {
showTopLevelStatus('Payment processing. Reference: ' + intentId, 'info');
}
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, status });

Copilot uses AI. Check for mistakes.
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);
const noFundsLink = document.getElementById('no-funds-add-funds');
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);
}

Loading
Loading