From 00e62bb0104fd4c75d2c9f29f9cf2a977d67a13e Mon Sep 17 00:00:00 2001 From: Will Anderson Date: Thu, 7 May 2026 01:00:51 -0500 Subject: [PATCH] Fix free tier checkout and Stripe duplicate customers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/checkout.el | 12 +++++++++++- src/js/checkout-auth.el | 15 +++++++++----- src/js/checkout-free.el | 13 ++++++++----- src/js/checkout-stripe.el | 23 ++++++++++++++++------ src/main.el | 41 ++++++++++++++++++++++++--------------- 5 files changed, 71 insertions(+), 33 deletions(-) diff --git a/src/checkout.el b/src/checkout.el index b769b29..2ac132a 100644 --- a/src/checkout.el +++ b/src/checkout.el @@ -97,7 +97,7 @@ fn checkout_page(plan: String, pub_key: String) -> String {
" + (if is_free { "

Create your account.

-

No charge today. Add your card to reserve your spot - you won't be billed until you upgrade.

+

No card required. Your account is free, forever.

" } else { "

Sign in (optional)

Sign in to link this purchase to an existing account. Or skip and create one later - we'll match it to your email.

@@ -135,6 +135,16 @@ fn checkout_page(plan: String, pub_key: String) -> String {
+ + " + (if is_free { " +
+
+

You're in.

+

Your free account is ready. Download Neuron to get started.

+ Go to your account → +
+ " } else { "" }) + " +
diff --git a/src/js/checkout-auth.el b/src/js/checkout-auth.el index 0e63984..fd244eb 100644 --- a/src/js/checkout-auth.el +++ b/src/js/checkout-auth.el @@ -33,8 +33,11 @@ fn main() -> Void { if (user && user.id) { window._neuronSupaId = user.id; } var auth = document.getElementById('auth-section'); if (auth) auth.style.display = 'none'; - var payment = document.getElementById('payment-section'); - if (payment) payment.style.display = ''; + var isFree = (window.NEURON_CFG || {}).plan === 'free'; + if (!isFree) { + var payment = document.getElementById('payment-section'); + if (payment) payment.style.display = ''; + } if (user) { var badge = document.getElementById('auth-badge'); @@ -55,9 +58,11 @@ fn main() -> Void { if (emailEl) emailEl.value = user.email; } - var userEmail = user ? (user.email || '') : ''; - var userName = user ? ((user.user_metadata && user.user_metadata.full_name) || '') : ''; - if (typeof initStripe === 'function') initStripe(userEmail, userName); + if (!isFree) { + var userEmail = user ? (user.email || '') : ''; + var userName = user ? ((user.user_metadata && user.user_metadata.full_name) || '') : ''; + if (typeof window.initStripe === 'function') window.initStripe(userEmail, userName); + } } function checkExistingSession() { diff --git a/src/js/checkout-free.el b/src/js/checkout-free.el index 257e295..9a475d1 100644 --- a/src/js/checkout-free.el +++ b/src/js/checkout-free.el @@ -1,15 +1,18 @@ -// checkout-free.el -- Free plan: reveal payment section after auth completes. -// Watches the auth-badge element; when it becomes visible, shows payment-section. +// checkout-free.el -- Free plan: show success panel after auth completes. +// Watches the auth-badge element; when it becomes visible, hides the auth +// section and shows the free-success panel. No card required for free tier. // Compiled with: elc --target=js --bundle --minify --obfuscate fn main() -> Void { native_js("(function() { - var pay = document.getElementById('payment-section'); - if (!pay) return; + var success = document.getElementById('free-success'); + var auth = document.getElementById('auth-section'); + if (!success) return; var timer = setInterval(function() { var badge = document.getElementById('auth-badge'); if (badge && badge.offsetParent !== null) { - pay.style.display = ''; + if (auth) auth.style.display = 'none'; + success.style.display = ''; clearInterval(timer); } }, 150); diff --git a/src/js/checkout-stripe.el b/src/js/checkout-stripe.el index 0bc6d54..d7d2213 100644 --- a/src/js/checkout-stripe.el +++ b/src/js/checkout-stripe.el @@ -31,8 +31,13 @@ fn main() -> Void { 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 { @@ -80,7 +85,7 @@ fn main() -> Void { return fetch('/api/payment-intent', { method: 'POST', headers: {'Content-Type': 'application/json'}, - body: JSON.stringify({ plan: PLAN, timing: timing }) + body: JSON.stringify({ plan: PLAN, timing: timing, email: userEmail, name: userName }) }) .then(function(r) { return r.json(); }) .then(function(data) { @@ -117,11 +122,17 @@ fn main() -> Void { }); } - 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); + // 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) { diff --git a/src/main.el b/src/main.el index c584df2..6cbecf6 100644 --- a/src/main.el +++ b/src/main.el @@ -584,23 +584,9 @@ fn handle_request_inner(method: String, path: String, body: String) -> String { } let timing: String = json_get_string(body, "timing") if str_eq(timing, "") { let timing = "now" } - // Free tier: SetupIntent — save card details without charging. - // Card is stored on a Stripe Customer; billing begins only if the - // user later upgrades to a paid plan. + // Free tier: no card required. Return immediately — no Stripe interaction. if str_eq(plan, "free") { - let si_body: String = "automatic_payment_methods[enabled]=true" - + "&usage=off_session" - + "&metadata[plan]=free" - let auth_header: String = "Bearer " + stripe_key - let si_resp: String = http_post_form_auth( - "https://api.stripe.com/v1/setup_intents", - si_body, - auth_header) - if str_starts_with(si_resp, "{") { - let inner: String = str_slice(si_resp, 1, str_len(si_resp)) - return "{\"setup_mode\":true,\"plan\":\"free\"," + inner - } - return si_resp + return "{\"plan\":\"free\",\"free\":true,\"no_payment_required\":true}" } // Hard cap: block founding checkouts when 1,000 spots are filled if str_eq(plan, "founding") { @@ -612,6 +598,27 @@ fn handle_request_inner(method: String, path: String, body: String) -> String { } let auth_header: String = "Bearer " + stripe_key + // Find-or-create Stripe Customer by email upfront so every intent + // is attached to an existing customer — prevents duplicate customers. + let pi_email: String = json_get_string(body, "email") + let pi_name: String = json_get_string(body, "name") + let pi_cus_id: String = "" + if !str_eq(pi_email, "") { + let pi_email_enc: String = str_replace(str_replace(pi_email, "@", "%40"), "+", "%2B") + let pi_search_url: String = "https://api.stripe.com/v1/customers/search?query=email%3A%22" + pi_email_enc + "%22&limit=1" + let pi_search: String = http_get_auth(pi_search_url, auth_header) + let pi_cus_id = json_get_string(pi_search, "id") + if str_eq(pi_cus_id, "") { + let pi_name_enc: String = str_replace(pi_name, " ", "%20") + let pi_cus_body: String = "email=" + pi_email_enc + + "&name=" + pi_name_enc + + "&metadata[plan]=" + plan + + "&metadata[source]=neuron-checkout" + let pi_cus_resp: String = http_post_form_auth("https://api.stripe.com/v1/customers", pi_cus_body, auth_header) + let pi_cus_id = json_get_string(pi_cus_resp, "id") + } + } + // Setup-mode path: save payment method, do not charge. Only valid // for Professional (Founding is one-shot lifetime, charges immediately). if str_eq(plan, "professional") && str_eq(timing, "later") { @@ -620,6 +627,7 @@ fn handle_request_inner(method: String, path: String, body: String) -> String { + "&metadata[plan]=" + plan + "&metadata[hold_until]=launch" + "&metadata[launch_target]=2026-09-01" + let si_body = if !str_eq(pi_cus_id, "") { si_body + "&customer=" + pi_cus_id } else { si_body } let si_resp: String = http_post_form_auth( "https://api.stripe.com/v1/setup_intents", si_body, @@ -642,6 +650,7 @@ fn handle_request_inner(method: String, path: String, body: String) -> String { + "&automatic_payment_methods[enabled]=true" + "&metadata[plan]=" + plan + "&metadata[timing]=" + timing + let pi_body = if !str_eq(pi_cus_id, "") { pi_body + "&customer=" + pi_cus_id } else { pi_body } let response: String = http_post_form_auth( "https://api.stripe.com/v1/payment_intents", pi_body,