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
+67 -11
View File
@@ -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 {
+ "&currency=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()