diff --git a/src/checkout.el b/src/checkout.el index 76ac9fc..ae11074 100644 --- a/src/checkout.el +++ b/src/checkout.el @@ -513,6 +513,10 @@ fn checkout_page(plan: String, pub_key: String) -> String { } function revealPaymentForm(user) { + // Capture Supabase user id so /api/link-customer can stamp the Stripe + // customer with metadata[supabase_user_id]. Used by /account to find + // the buyer's plan via the canonical cross-reference. + if (user && user.id) { window._neuronSupaId = user.id; } // Hide optional auth section if it was shown var auth = document.getElementById('auth-section'); if (auth) auth.style.display = 'none'; @@ -684,93 +688,114 @@ fn checkout_page(plan: String, pub_key: String) -> String { spinner.style.display = loading ? '' : 'none'; } - // Fetch client secret from our server - fetch('/api/payment-intent', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ plan: PLAN }) - }) - .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.'); - var submitBtn = document.getElementById('submit-btn'); - if (submitBtn) { submitBtn.disabled = true; submitBtn.textContent = 'Sold out'; } - return; + // Mode flag: 'payment' (charge now) or 'setup' (save card, charge later). + // The submit handler reads this to choose stripe.confirmPayment vs + // stripe.confirmSetup. SOURCE OF TRUTH for whether the buyer is going to + // be charged at submit time. If the radio toggles, we re-fetch a new + // client_secret of the right type and re-mount the Element. + window._neuronMode = 'payment'; + var paymentEl = null; + + 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; } - if (!data.client_secret) { - showMessage('Unable to initialise payment. Please try again.'); - return; + var loadEl = document.querySelector('.checkout-element-loading'); + if (!loadEl) { + var hostEl = document.getElementById('payment-element'); + if (hostEl) { + var d = document.createElement('div'); + d.className = 'checkout-element-loading'; + d.textContent = 'Loading payment form…'; + hostEl.appendChild(d); + } } - - window._neuronPiId = data.id || (data.client_secret ? data.client_secret.split('_secret_')[0] : ''); - - waitForStripe(function() { - stripe = Stripe(STRIPE_PK); - - elements = stripe.elements({ - clientSecret: data.client_secret, - appearance: { - 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' }, - } - } - }); - - var paymentEl = elements.create('payment', { - fields: { billingDetails: { name: 'never', email: 'never' } } - }); - paymentEl.mount('#payment-element'); - - paymentEl.on('ready', function() { - document.querySelector('.checkout-element-loading') && - document.querySelector('.checkout-element-loading').remove(); - document.getElementById('submit-btn').disabled = false; + var timing = currentTiming(); + return fetch('/api/payment-intent', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ plan: PLAN, timing: timing }) + }) + .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] : ''); + // Update the submit button label so users know what will happen. + 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.'); }); - }) - .catch(function() { - showMessage('Unable to connect. Please check your connection and try again.'); - }); + } + + // Initial mount. PLAN==professional shows a timing radio; toggling it + // re-fetches the right Intent so the buyer's choice is honored. + 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); // Submit document.getElementById('payment-form').addEventListener('submit', async function(e) { @@ -822,24 +847,43 @@ fn checkout_page(plan: String, pub_key: String) -> String { 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 }) + 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) { /* non-blocking */ } } - var result = await stripe.confirmPayment({ - elements: elements, - confirmParams: { - return_url: window.location.origin + '/account?welcome=1', - payment_method_data: { - billing_details: { name: name, email: email } - }, - receipt_email: email, - } - }); + // Setup mode: save card, do not charge. Use stripe.confirmSetup. + // Payment mode: charge now via stripe.confirmPayment. + 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 || 'Payment failed. Please try again.'); + showMessage(result.error.message || (window._neuronMode === 'setup' + ? 'Could not save your card. Please try again.' + : 'Payment failed. Please try again.')); setLoading(false); } // On success, Stripe redirects to return_url automatically. diff --git a/src/main.el b/src/main.el index 9a4b3a2..ff16ee4 100644 --- a/src/main.el +++ b/src/main.el @@ -403,7 +403,13 @@ fn handle_request(method: String, path: String, body: String) -> String { return page_open() + checkout_page(plan, pub_key) + page_close() } - // ── Stripe payment intent ───────────────────────────────────────────────── + // ── Stripe payment intent / setup intent ───────────────────────────────── + // body fields: + // plan: "founding" | "professional" | "free" + // timing: "now" | "later" (Professional only; Founding always charges) + // When timing == "later" we create a SetupIntent so the buyer's card is + // saved without being charged. A Subscription with trial_end at launch + // (Q3 2026) will be created later from the saved payment method. if str_eq(path, "/api/payment-intent") { let stripe_key: String = state_get("__stripe_secret_key__") if str_eq(stripe_key, "") { @@ -413,6 +419,8 @@ fn handle_request(method: String, path: String, body: String) -> String { if str_contains(body, "\"professional\"") { plan = "professional" } + let timing: String = json_get_string(body, "timing") + if str_eq(timing, "") { let timing = "now" } // Hard cap: block founding checkouts when 1,000 spots are filled if str_eq(plan, "founding") { let current_sold: Int = get_sold() @@ -421,6 +429,29 @@ fn handle_request(method: String, path: String, body: String) -> String { return "{\"__status__\":410,\"error\":\"sold_out\",\"message\":\"All 1,000 Founding Member spots have been claimed.\"}" } } + let auth_header: String = "Bearer " + stripe_key + + // 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") { + let si_body: String = "automatic_payment_methods[enabled]=true" + + "&usage=off_session" + + "&metadata[plan]=" + plan + + "&metadata[hold_until]=launch" + + "&metadata[launch_target]=2026-09-01" + let si_resp: String = http_post_form_auth( + "https://api.stripe.com/v1/setup_intents", + si_body, + auth_header) + // Splice in setup_mode marker so the frontend knows to call + // stripe.confirmSetup instead of stripe.confirmPayment. + if str_starts_with(si_resp, "{") { + let inner: String = str_slice(si_resp, 1, str_len(si_resp)) + return "{\"setup_mode\":true,\"plan\":\"" + plan + "\"," + inner + } + return si_resp + } + let amount: String = "19900" if str_eq(plan, "professional") { amount = "1900" @@ -429,7 +460,7 @@ fn handle_request(method: String, path: String, body: String) -> String { + "¤cy=usd" + "&automatic_payment_methods[enabled]=true" + "&metadata[plan]=" + plan - let auth_header: String = "Bearer " + stripe_key + + "&metadata[timing]=" + timing let response: String = http_post_form_auth( "https://api.stripe.com/v1/payment_intents", pi_body, @@ -455,6 +486,7 @@ fn handle_request(method: String, path: String, body: String) -> String { let lc_email: String = json_get_string(body, "email") let lc_name: String = json_get_string(body, "name") let lc_plan: String = json_get_string(body, "plan") + let lc_supa: String = json_get_string(body, "supabase_user_id") if str_eq(lc_pi_id, "") || str_eq(lc_email, "") { return "{\"linked\":false,\"error\":\"missing_pi_or_email\"}" } @@ -468,11 +500,25 @@ fn handle_request(method: String, path: String, body: String) -> String { let lc_search: String = http_get_auth(lc_search_url, lc_auth) let lc_cus_id: String = json_get_string(lc_search, "id") - // 2. If none, create one + // 2. If none, create one. We always include supabase_user_id so the + // Stripe Customer cross-references back to our auth identity. if str_eq(lc_cus_id, "") { - let lc_create_body: String = "email=" + lc_email_enc + "&name=" + lc_name_enc + "&metadata[plan]=" + lc_plan - let lc_create: String = http_post_form_auth("https://api.stripe.com/v1/customers", lc_create_body, lc_auth) + let lc_create_body: String = "email=" + lc_email_enc + + "&name=" + lc_name_enc + + "&description=" + lc_name_enc + + "&metadata[plan]=" + lc_plan + + "&metadata[source]=neuron-checkout" + + "&metadata[supabase_user_id]=" + lc_supa + let lc_create: String = http_post_form_auth("https://api.stripe.com/v1/customers", lc_create_body, lc_auth) let lc_cus_id = json_get_string(lc_create, "id") + } else { + // Existing customer: stamp the supabase_user_id if we now know it + // (e.g. they signed in after a guest-style purchase). + if !str_eq(lc_supa, "") { + let lc_patch_body: String = "metadata[supabase_user_id]=" + lc_supa + "&metadata[plan]=" + lc_plan + let lc_patch_url: String = "https://api.stripe.com/v1/customers/" + lc_cus_id + let _lc_patch: String = http_post_form_auth(lc_patch_url, lc_patch_body, lc_auth) + } } // 3. Attach customer + receipt_email to the PaymentIntent @@ -534,13 +580,23 @@ fn handle_request(method: String, path: String, body: String) -> String { // ── Founding count ──────────────────────────────────────────────────────── + // Live Stripe query is ~1s. Cache the result for FOUNDING_CACHE_TTL_S + // and serve cached values otherwise. The Stripe webhook bumps the + // counter on payment_intent.succeeded, so the cache going stale by + // 30s does not affect freshness for actual purchases. if str_eq(path, "/api/founding-count") { - // Query Stripe live so counter always reflects real purchases - let stripe_key: String = state_get("__stripe_secret_key__") - let live_sold: Int = fetch_founding_count_stripe(stripe_key) - if live_sold > get_sold() { - state_set("__founding_sold__", int_to_str(live_sold)) - persist_founding_count(live_sold) + let now: Int = unix_timestamp() + let cached_at_str: String = state_get("__founding_cached_at__") + let cached_at: Int = if str_eq(cached_at_str, "") { 0 } else { str_to_int(cached_at_str) } + let ttl: Int = 30 + if (now - cached_at) > ttl { + let stripe_key: String = state_get("__stripe_secret_key__") + let live_sold: Int = fetch_founding_count_stripe(stripe_key) + if live_sold > get_sold() { + state_set("__founding_sold__", int_to_str(live_sold)) + persist_founding_count(live_sold) + } + state_set("__founding_cached_at__", int_to_str(now)) } let sold: Int = get_sold() let total: Int = get_total()