Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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 { 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';
Expand Down Expand Up @@ -154,6 +154,7 @@ setOnAuthReady(() => {
updateStatCards();
preloadAllTables();
updateUsageKeyOverrideUI();
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