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.
This commit is contained in:
Will Anderson
2026-05-01 23:34:52 -05:00
parent 702888d3aa
commit d6731f7834
2 changed files with 79 additions and 51 deletions
+18 -1
View File
@@ -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 }
},
+61 -50
View File
@@ -288,54 +288,7 @@ body::before{content:'';position:fixed;inset:0;pointer-events:none;z-index:0;bac
<a href=\"https://neurontechnologies.ai\" class=\"cta-btn\">Try Neuron &#8599;</a>
</div>
</div>
<script>
(function() {
var CARD_ID = '" + id + "';
var CARD_URL = '" + card_url + "';
var voted = JSON.parse(localStorage.getItem('neuron_voted') || '{}');
var upBtn = document.getElementById('vote-up');
var downBtn = document.getElementById('vote-down');
var scoreEl = document.getElementById('vote-score');
fetch('/api/vote-count/' + CARD_ID).then(function(r){return r.json();}).then(function(d){
if (scoreEl) scoreEl.textContent = (d.score || 0);
}).catch(function(){});
if (voted[CARD_ID] === 'up') { upBtn.classList.add('voted-up'); downBtn.classList.add('voted-down'); }
else if (voted[CARD_ID] === 'down') { downBtn.classList.add('voted-down'); upBtn.classList.add('voted-up'); }
function doVote(dir) {
if (voted[CARD_ID]) return;
voted[CARD_ID] = dir;
localStorage.setItem('neuron_voted', JSON.stringify(voted));
if (dir === 'up') { upBtn.classList.add('voted-up'); downBtn.classList.add('voted-down'); }
else { downBtn.classList.add('voted-down'); upBtn.classList.add('voted-up'); }
var prev = parseInt(scoreEl.textContent) || 0;
scoreEl.textContent = prev + (dir === 'up' ? 1 : -1);
fetch('/api/vote', {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({id:CARD_ID,direction:dir})});
}
upBtn.onclick = function() { doVote('up'); };
downBtn.onclick = function() { doVote('down'); };
window.copyForPlatform = function(platform, btn) {
var label = platform === 'tiktok' ? 'Copied — paste into TikTok' : 'Copied — paste into Snapchat';
navigator.clipboard.writeText(CARD_URL).then(function() {
var orig = btn.textContent.trim();
btn.classList.add('copied');
var svgEl = btn.querySelector('svg');
var svgHtml = svgEl ? svgEl.outerHTML : '';
btn.innerHTML = svgHtml + ' ' + label;
setTimeout(function() {
btn.classList.remove('copied');
btn.innerHTML = svgHtml + ' ' + (platform === 'tiktok' ? 'TikTok' : 'Snapchat');
}, 2500);
}).catch(function() {
// fallback: prompt
window.prompt('Copy this link:', CARD_URL);
});
};
})();
</script>
<script>window.NEURON_CFG=window.NEURON_CFG||{};window.NEURON_CFG.id=\"" + id + "\";window.NEURON_CFG.card_url=\"" + card_url + "\";</script><script src=\"/assets/js/94727a87c328.js\" defer></script>
</body>
</html>"
}
@@ -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)