From d6731f783498eeb5364974d79df5471a47245bc4 Mon Sep 17 00:00:00 2001 From: Will Anderson Date: Fri, 1 May 2026 23:34:52 -0500 Subject: [PATCH] checkout: re-add link-customer + redirect to /account, fix success/account routes Restores the /api/link-customer endpoint that was lost in the stash. It runs right before stripe.confirmPayment() and: - searches Stripe for an existing Customer by email - creates one if missing (URL-encodes + and @ so Gmail aliases work) - attaches the Customer to the live PaymentIntent - upserts the Supabase waitlist row with stripe_customer_id + plan Stripe locks customer on a Charge once set, so the webhook handler is the second-line backstop for any race where confirmPayment beats the link call. Also: change return_url from /marketplace/success to /account?welcome=1 so buyers land where they need to be, and switch /marketplace/success and /account from str_eq to str_starts_with so Stripe-appended query strings (?payment_intent=...&redirect_status=succeeded) don't 404. --- src/checkout.el | 19 ++++++++- src/main.el | 111 ++++++++++++++++++++++++++---------------------- 2 files changed, 79 insertions(+), 51 deletions(-) diff --git a/src/checkout.el b/src/checkout.el index e8f7c52..bb75081 100644 --- a/src/checkout.el +++ b/src/checkout.el @@ -703,6 +703,9 @@ fn checkout_page(plan: String, pub_key: String) -> String { return; } + // Capture the PI id so we can attach a Customer at submit time + window._neuronPiId = data.id || (data.client_secret ? data.client_secret.split('_secret_')[0] : ''); + waitForStripe(function() { stripe = Stripe(STRIPE_PK); @@ -815,10 +818,24 @@ fn checkout_page(plan: String, pub_key: String) -> String { setLoading(true); document.getElementById('payment-message').style.display = 'none'; + // Link a Stripe Customer to this PaymentIntent and to the Supabase waitlist row + // (so the buyer shows up as a named customer, not Guest, and the account page + // can find their plan via stripe_customer_id). Non-blocking - if it fails, the + // webhook still links them server-side after payment_intent.succeeded fires. + 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 }) + }); + } catch(e) { /* non-blocking */ } + } + var result = await stripe.confirmPayment({ elements: elements, confirmParams: { - return_url: window.location.origin + '/marketplace/success', + return_url: window.location.origin + '/account?welcome=1', payment_method_data: { billing_details: { name: name, email: email } }, diff --git a/src/main.el b/src/main.el index c6897fc..a89af61 100644 --- a/src/main.el +++ b/src/main.el @@ -288,54 +288,7 @@ body::before{content:'';position:fixed;inset:0;pointer-events:none;z-index:0;bac Try Neuron ↗ - + " } @@ -472,6 +425,61 @@ fn handle_request(method: String, path: String, body: String) -> String { return response } + // ── Link Stripe Customer to PaymentIntent + Supabase waitlist row ──────── + // Called from checkout.js right before stripe.confirmPayment. Three jobs: + // 1. Find or create a Stripe Customer for this email (so they aren't a Guest) + // 2. Attach the Customer to the live PaymentIntent + // 3. Stamp stripe_customer_id + plan on the Supabase waitlist row (upsert by email) + // Non-fatal: if any step errors, we still return 200 so the buyer can complete + // payment. The Stripe webhook ( /api/webhooks/stripe ) re-links server-side on + // payment_intent.succeeded as a backstop. + if str_eq(path, "/api/link-customer") { + let stripe_key: String = state_get("__stripe_secret_key__") + if str_eq(stripe_key, "") { + return "{\"linked\":false,\"error\":\"stripe_not_configured\"}" + } + let lc_pi_id: String = json_get_string(body, "pi_id") + 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") + if str_eq(lc_pi_id, "") || str_eq(lc_email, "") { + return "{\"linked\":false,\"error\":\"missing_pi_or_email\"}" + } + let lc_auth: String = "Bearer " + stripe_key + // URL-encode email so + and @ survive the form-body + let lc_email_enc: String = str_replace(str_replace(lc_email, "@", "%40"), "+", "%2B") + let lc_name_enc: String = str_replace(lc_name, " ", "%20") + + // 1. Search existing customers by email + let lc_search_url: String = "https://api.stripe.com/v1/customers/search?query=email%3A%22" + lc_email_enc + "%22&limit=1" + 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 + 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_cus_id = json_get_string(lc_create, "id") + } + + // 3. Attach customer + receipt_email to the PaymentIntent + if !str_eq(lc_cus_id, "") { + let lc_attach_body: String = "customer=" + lc_cus_id + "&receipt_email=" + lc_email_enc + let lc_attach_url: String = "https://api.stripe.com/v1/payment_intents/" + lc_pi_id + let lc_attach: String = http_post_form_auth(lc_attach_url, lc_attach_body, lc_auth) + } + + // 4. Upsert the Supabase waitlist row so /account can find this purchase by email + let sb_url: String = state_get("__supabase_project_url__") + let sb_key: String = state_get("__supabase_service_key__") + if !str_eq(sb_url, "") && !str_eq(sb_key, "") && !str_eq(lc_cus_id, "") { + let lc_row: String = "{\"email\":\"" + lc_email + "\",\"name\":\"" + lc_name + "\",\"stripe_customer_id\":\"" + lc_cus_id + "\",\"plan\":\"" + lc_plan + "\"}" + let _wl_resp: String = supabase_insert(sb_url, sb_key, "waitlist?on_conflict=email", lc_row) + } + + return "{\"linked\":true,\"customer_id\":\"" + lc_cus_id + "\"}" + } + // ── Health check ────────────────────────────────────────────────────────── if str_eq(path, "/api/health") { return "{\"status\":\"ok\",\"service\":\"neuron-web\"}" @@ -921,7 +929,9 @@ fn handle_request(method: String, path: String, body: String) -> String { } // ── Checkout success ────────────────────────────────────────────────────── - if str_eq(path, "/marketplace/success") { + // Stripe appends ?payment_intent=...&payment_intent_client_secret=...&redirect_status=succeeded + // to the return_url. Use prefix match so the query string does not 404. + if str_starts_with(path, "/marketplace/success") { let badge_html: String = founding_badge(get_sold()) let badge_css: String = founding_badge_css() return page_open() + badge_css + " @@ -941,7 +951,8 @@ fn handle_request(method: String, path: String, body: String) -> String { } // ── Account dashboard ───────────────────────────────────────────────────── - if str_eq(path, "/account") { + // Use prefix match so OAuth/email-confirmation redirects with query strings still hit. + if str_starts_with(path, "/account") { let sb_url: String = state_get("__supabase_project_url__") let sb_anon: String = state_get("__supabase_anon_key__") return account_page(sb_url, sb_anon)