Files
neuron-web/src/js/checkout-stripe.el
T
will.anderson 00e62bb010 Fix free tier checkout and Stripe duplicate customers
Free tier:
- checkout-stripe.el bails out immediately for plan=free (no Stripe init)
- checkout-auth.el skips payment section reveal and initStripe for free plan
- checkout-free.el shows #free-success panel after auth (no card ever shown)
- /api/payment-intent returns early for free plan — no Stripe call

Stripe dedup (all paid plans):
- Stripe init now deferred to window.initStripe(email, name), called by
  checkout-auth.el after sign-in — email is known before intent is created
- /api/payment-intent finds-or-creates Stripe Customer by email before
  creating the PaymentIntent/SetupIntent and attaches customer upfront
- Eliminates the window between intent creation and /api/link-customer
  that was producing duplicate guest customers
2026-05-07 01:00:51 -05:00

212 lines
8.2 KiB
EmacsLisp

// checkout-stripe.el -- Stripe Payment Element setup and form submission.
// Reads NEURON_CFG.plan and NEURON_CFG.pub_key from window.
// Compiled with: elc --target=js --bundle --minify --obfuscate
//
// Required globals: window.NEURON_CFG.plan, window.NEURON_CFG.pub_key
// Required CDN: Stripe.js loaded before this script
fn main() -> Void {
native_js("(function() {
var cfg = window.NEURON_CFG || {};
var PLAN = cfg.plan || '';
var STRIPE_PK = cfg.pub_key || '';
var stripe, elements;
function waitForStripe(cb) {
if (window.Stripe) { cb(); return; }
setTimeout(function() { waitForStripe(cb); }, 50);
}
function showMessage(msg) {
var el = document.getElementById('payment-message');
if (el) { el.textContent = msg; el.style.display = 'block'; }
}
function setLoading(loading) {
var btn = document.getElementById('submit-btn');
var label = document.getElementById('submit-label');
var spinner = document.getElementById('submit-spinner');
if (btn) btn.disabled = loading;
if (label) label.style.display = loading ? 'none' : '';
if (spinner) spinner.style.display = loading ? '' : 'none';
}
// Free plan has no payment form — bail out entirely.
if (str_eq(PLAN, 'free')) return;
window._neuronMode = 'payment';
var paymentEl = null;
var userEmail = '';
var userName = '';
function appearance() {
return {
theme: 'flat',
variables: {
colorPrimary: '#0052A0',
colorBackground: '#ffffff',
colorText: '#1A1A2E',
colorDanger: '#c0392b',
colorTextPlaceholder: '#9B9BAD',
borderRadius: '0px',
fontFamily: 'system-ui, -apple-system, sans-serif',
fontSizeBase: '15px',
fontWeightNormal: '300',
spacingUnit: '4px'
},
rules: {
'.Input': { border: '1px solid rgba(0,82,160,.22)', boxShadow: 'none', padding: '10px 14px' },
'.Input:focus': { border: '1px solid rgba(0,82,160,.6)', boxShadow: '0 0 0 3px rgba(0,82,160,.08)', outline: 'none' },
'.Label': { fontSize: '11px', fontWeight: '500', letterSpacing: '.06em', textTransform: 'uppercase', color: '#6B6B7E', marginBottom: '6px' },
'.Tab': { border: '1px solid rgba(0,82,160,.18)', boxShadow: 'none' },
'.Tab--selected':{ border: '1px solid rgba(0,82,160,.5)', boxShadow: '0 0 0 2px rgba(0,82,160,.12)' },
'.Error': { color: '#c0392b' }
}
};
}
function currentTiming() {
var later = document.getElementById('timing-later');
return (later && later.checked) ? 'later' : 'now';
}
function fetchAndMount() {
var submitBtn = document.getElementById('submit-btn');
if (submitBtn) submitBtn.disabled = true;
if (paymentEl) { try { paymentEl.unmount(); } catch(e) {} paymentEl = null; }
var hostEl = document.getElementById('payment-element');
if (hostEl && !document.querySelector('.checkout-element-loading')) {
var d = document.createElement('div');
d.className = 'checkout-element-loading';
d.textContent = 'Loading payment form...';
hostEl.appendChild(d);
}
var timing = currentTiming();
return fetch('/api/payment-intent', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ plan: PLAN, timing: timing, email: userEmail, name: userName })
})
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.error === 'sold_out') {
showMessage('All 1,000 Founding Member spots have been claimed. Thank you for your interest - please consider the Professional plan.');
if (submitBtn) { submitBtn.disabled = true; submitBtn.textContent = 'Sold out'; }
return;
}
if (!data.client_secret) { showMessage('Unable to initialise payment. Please try again.'); return; }
window._neuronMode = data.setup_mode ? 'setup' : 'payment';
window._neuronPiId = data.id || (data.client_secret ? data.client_secret.split('_secret_')[0] : '');
var submitLabel = document.getElementById('submit-label');
if (submitLabel) {
submitLabel.textContent = window._neuronMode === 'setup'
? 'Save my card - no charge today →'
: 'Complete purchase →';
}
waitForStripe(function() {
if (!stripe) stripe = Stripe(STRIPE_PK);
elements = stripe.elements({ clientSecret: data.client_secret, appearance: appearance() });
paymentEl = elements.create('payment', {
fields: { billingDetails: { name: 'never', email: 'never' } }
});
paymentEl.mount('#payment-element');
paymentEl.on('ready', function() {
var ld = document.querySelector('.checkout-element-loading');
if (ld) ld.remove();
if (submitBtn) submitBtn.disabled = false;
});
});
})
.catch(function() {
showMessage('Unable to connect. Please check your connection and try again.');
});
}
// Don't init Stripe at page load — wait for auth.
// checkout-auth.el calls window.initStripe(email, name) after sign-in.
window.initStripe = function(email, name) {
userEmail = email || '';
userName = name || '';
fetchAndMount();
var tNow = document.getElementById('timing-now');
var tLater = document.getElementById('timing-later');
if (tNow) tNow.addEventListener('change', fetchAndMount);
if (tLater) tLater.addEventListener('change', fetchAndMount);
};
var form = document.getElementById('payment-form');
if (form) form.addEventListener('submit', async function(e) {
e.preventDefault();
if (!stripe || !elements) return;
var attestCb = document.getElementById('founding-attest-cb');
if (attestCb && !attestCb.checked) {
var warn = document.getElementById('attest-warn');
if (warn) warn.style.display = 'block';
attestCb.closest('label').scrollIntoView({ behavior: 'smooth', block: 'center' });
return;
}
var name = document.getElementById('buyer-name').value.trim();
var email = document.getElementById('buyer-email').value.trim();
if (!name || !email) { showMessage('Please enter your name and email.'); return; }
if (attestCb) {
try {
await fetch('/api/attest', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
plan: PLAN, name: name, email: email,
timestamp: new Date().toISOString(),
attestation: 'I am joining as a genuine early user, not to extract proprietary information about Neuron technology, architecture, or roadmap. I will engage in good faith. I understand that if this is not my intent, a different plan is a better fit.',
user_agent: navigator.userAgent
})
});
} catch(e) {}
}
setLoading(true);
var pmsg = document.getElementById('payment-message');
if (pmsg) pmsg.style.display = 'none';
if (window._neuronPiId) {
try {
await fetch('/api/link-customer', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
pi_id: window._neuronPiId,
email: email,
name: name,
plan: PLAN,
timing: currentTiming(),
mode: window._neuronMode || 'payment',
supabase_user_id: window._neuronSupaId || ''
})
});
} catch(e) {}
}
var confirmParams = {
return_url: window.location.origin + '/account?welcome=1',
payment_method_data: { billing_details: { name: name, email: email } }
};
var result;
if (window._neuronMode === 'setup') {
result = await stripe.confirmSetup({ elements: elements, confirmParams: confirmParams });
} else {
confirmParams.receipt_email = email;
result = await stripe.confirmPayment({ elements: elements, confirmParams: confirmParams });
}
if (result.error) {
showMessage(result.error.message || (window._neuronMode === 'setup'
? 'Could not save your card. Please try again.'
: 'Payment failed. Please try again.'));
setLoading(false);
}
});
})()")
}