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:
Will Anderson
2026-05-02 00:45:44 -05:00
parent 04f3afea09
commit 1349450b14
2 changed files with 206 additions and 106 deletions
+139 -95
View File
@@ -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.