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 { "
+
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,