checkout: hold-until-launch radio actually works (SetupIntent path)
The Professional plan's "Hold until product launches" radio was wired
into the markup but ignored by both /api/payment-intent and the JS
submit handler. Buyers who picked it would still get charged
immediately because the server always created a PaymentIntent and
the client always called stripe.confirmPayment.
Fix:
* /api/payment-intent reads body.timing. When plan=professional and
timing=later, it creates a SetupIntent (usage=off_session) instead
of a PaymentIntent and returns {setup_mode:true, client_secret:...}.
Founding stays unconditional (lifetime, charge now).
* checkout JS now reads the radio (currentTiming()), passes timing
to the server, and re-fetches a new client_secret on radio change
so the buyer's choice is honored even if they toggle after first
mount.
* window._neuronMode tracks 'payment' vs 'setup'. The submit handler
branches: stripe.confirmSetup for save-card, stripe.confirmPayment
for charge-now. The submit button label updates to "Save my card -
no charge today" when in setup mode so the buyer sees the
intent before they hit submit.
* /api/link-customer receives timing + mode so the server can
differentiate at attach time.
A future webhook on setup_intent.succeeded will create the actual
Subscription with trial_end at launch (Q3 2026 / 2026-09-01) - that
piece is queued via metadata[hold_until]=launch on the SetupIntent.
For now, the saved payment method sits in Stripe untouched.
The point: a buyer who picks "Hold until launch" is NOT charged. The
flow has to be airtight - no surprise charges.
This commit is contained in:
+139
-95
@@ -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.
|
||||
|
||||
+67
-11
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user