From 32172d3fdf1691074f8f43f0a29eceefff1acb59 Mon Sep 17 00:00:00 2001 From: nams1570 Date: Thu, 21 May 2026 14:08:52 -0700 Subject: [PATCH 1/2] fix: checkout flow for 0 dollar subscription 0 dollar subs on stripe don't create any client secrets --- .../src/app/(main)/purchase/[code]/page-client.tsx | 14 ++++++++++++-- .../src/app/(main)/purchase/return/page-client.tsx | 14 ++++++++++++-- .../src/app/(main)/purchase/return/page.tsx | 2 ++ .../dashboard/src/components/payments/checkout.tsx | 13 +++++++++++++ 4 files changed, 39 insertions(+), 4 deletions(-) diff --git a/apps/dashboard/src/app/(main)/purchase/[code]/page-client.tsx b/apps/dashboard/src/app/(main)/purchase/[code]/page-client.tsx index c01feb43dd..6a2f0dce39 100644 --- a/apps/dashboard/src/app/(main)/purchase/[code]/page-client.tsx +++ b/apps/dashboard/src/app/(main)/purchase/[code]/page-client.tsx @@ -12,7 +12,6 @@ import Image from 'next/image'; import { useSearchParams } from "next/navigation"; import { useCallback, useEffect, useMemo, useState } from "react"; import * as yup from "yup"; - type ProductData = { product?: Omit, "included_items" | "server_only"> & { stackable: boolean }, stripe_account_id: string, @@ -143,6 +142,16 @@ export default function PageClient({ code }: { code: string }) { }); }, [validateCode]); + // True iff the price the user is about to purchase is $0. The backend + // intentionally omits client_secret for $0 subs (Stripe activates them + // synchronously, nothing to confirm), so this drives both the + // missing-secret-is-ok check below and the skip-Stripe-Elements branch in + // CheckoutForm. + const isFreeSelected = useMemo(() => { + if (!selectedPriceId || !data?.product?.prices) return false; + return Number(data.product.prices[selectedPriceId].USD) === 0; + }, [data, selectedPriceId]); + const setupSubscription = async () => { const response = await fetch(`${baseUrl}/payments/purchases/purchase-session`, { method: 'POST', @@ -150,7 +159,7 @@ export default function PageClient({ code }: { code: string }) { body: JSON.stringify({ full_code: code, price_id: selectedPriceId, quantity: quantityNumber }), }); const result = await response.json(); - if (!result.client_secret) { + if (!result.client_secret && !isFreeSelected) { throw new Error("Failed to setup subscription"); } return result.client_secret; @@ -392,6 +401,7 @@ export default function PageClient({ code }: { code: string }) { disabled={quantityNumber < 1 || isTooLarge || data.already_bought_non_stackable === true} chargesEnabled={data.charges_enabled} onTestModeBypass={data.test_mode ? handleBypass : undefined} + isFree={isFreeSelected} /> diff --git a/apps/dashboard/src/app/(main)/purchase/return/page-client.tsx b/apps/dashboard/src/app/(main)/purchase/return/page-client.tsx index 347e2b1c11..c2baceafc8 100644 --- a/apps/dashboard/src/app/(main)/purchase/return/page-client.tsx +++ b/apps/dashboard/src/app/(main)/purchase/return/page-client.tsx @@ -16,6 +16,7 @@ type Props = { stripeAccountId?: string, purchaseFullCode?: string, bypass?: string, + free?: string, }; type ViewState = @@ -27,7 +28,7 @@ const stripePublicKey = getPublicEnvVar("NEXT_PUBLIC_STACK_STRIPE_PUBLISHABLE_KE const apiUrl = getPublicEnvVar("NEXT_PUBLIC_STACK_API_URL") ?? throwErr("NEXT_PUBLIC_STACK_API_URL is not set"); const baseUrl = new URL("/api/v1", apiUrl).toString(); -export default function ReturnClient({ clientSecret, stripeAccountId, purchaseFullCode, bypass }: Props) { +export default function ReturnClient({ clientSecret, stripeAccountId, purchaseFullCode, bypass, free }: Props) { const [state, setState] = useState({ kind: "loading" }); const searchParams = useSearchParams(); const returnUrl = searchParams.get("return_url"); @@ -53,6 +54,15 @@ export default function ReturnClient({ clientSecret, stripeAccountId, purchaseFu setState({ kind: "success", message }); return; } + if (free === "1") { + // $0 subs activate synchronously on the Stripe side and produce no + // PaymentIntent / client_secret, so there's nothing to retrieve — + // mirror the bypass branch and show terminal success. + runAsynchronously(checkAndReturnUser()); + const message = `Free subscription activated. No payment required.${returnUrl ? " You will be redirected shortly." : ""}`; + setState({ kind: "success", message }); + return; + } const stripe = await loadStripe(stripePublicKey, { stripeAccount: stripeAccountId }); if (!stripe) throw new Error("Stripe failed to initialize"); if (!clientSecret) return; @@ -87,7 +97,7 @@ export default function ReturnClient({ clientSecret, stripeAccountId, purchaseFu const message = e instanceof Error ? e.message : "Unexpected error retrieving payment."; setState({ kind: "error", message }); } - }, [clientSecret, stripeAccountId, bypass, returnUrl, checkAndReturnUser]); + }, [clientSecret, stripeAccountId, bypass, free, returnUrl, checkAndReturnUser]); useEffect(() => { runAsynchronously(updateViewState()); diff --git a/apps/dashboard/src/app/(main)/purchase/return/page.tsx b/apps/dashboard/src/app/(main)/purchase/return/page.tsx index fcce9bf6b1..dd6190c785 100644 --- a/apps/dashboard/src/app/(main)/purchase/return/page.tsx +++ b/apps/dashboard/src/app/(main)/purchase/return/page.tsx @@ -9,6 +9,7 @@ type Props = { stripe_account_id?: string, purchase_full_code?: string, bypass?: string, + free?: string, }>, }; @@ -22,6 +23,7 @@ export default async function Page({ searchParams }: Props) { stripeAccountId={params.stripe_account_id} purchaseFullCode={params.purchase_full_code} bypass={params.bypass} + free={params.free} /> ); } diff --git a/apps/dashboard/src/components/payments/checkout.tsx b/apps/dashboard/src/components/payments/checkout.tsx index f8c0d6c4da..175fae7f82 100644 --- a/apps/dashboard/src/components/payments/checkout.tsx +++ b/apps/dashboard/src/components/payments/checkout.tsx @@ -25,6 +25,7 @@ type Props = { disabled?: boolean, onTestModeBypass?: () => Promise, chargesEnabled: boolean, + isFree: boolean, }; export function CheckoutForm({ @@ -35,6 +36,7 @@ export function CheckoutForm({ disabled, onTestModeBypass, chargesEnabled, + isFree, }: Props) { const stripe = useStripe(); const elements = useElements(); @@ -57,6 +59,17 @@ export function CheckoutForm({ stripeReturnUrl.searchParams.set("return_url", returnUrl); } + if (isFree) { + // $0 subs: backend creates the Stripe subscription synchronously and + // returns no client_secret (nothing to confirm). Skip Stripe Elements + // and route through /purchase/return with `free=1` so the return page + // renders a terminal success state instead of waiting on a Stripe + // PaymentIntent that will never exist. The return page handles the + // `return_url` bounce (or shows the success page when none was given). + stripeReturnUrl.searchParams.set("free", "1"); + window.location.assign(stripeReturnUrl.toString()); + return; + } const { error } = await stripe.confirmPayment({ elements, clientSecret, From fe0c734617547e54f03f756aa276c30fe44847fc Mon Sep 17 00:00:00 2001 From: nams1570 Date: Thu, 21 May 2026 14:32:36 -0700 Subject: [PATCH 2/2] fix: better safeguards and error messages --- .../src/app/(main)/purchase/[code]/page-client.tsx | 9 +++++++-- .../src/components/payments/create-checkout-dialog.tsx | 2 ++ 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/apps/dashboard/src/app/(main)/purchase/[code]/page-client.tsx b/apps/dashboard/src/app/(main)/purchase/[code]/page-client.tsx index 6a2f0dce39..e401c9cd35 100644 --- a/apps/dashboard/src/app/(main)/purchase/[code]/page-client.tsx +++ b/apps/dashboard/src/app/(main)/purchase/[code]/page-client.tsx @@ -146,10 +146,10 @@ export default function PageClient({ code }: { code: string }) { // intentionally omits client_secret for $0 subs (Stripe activates them // synchronously, nothing to confirm), so this drives both the // missing-secret-is-ok check below and the skip-Stripe-Elements branch in - // CheckoutForm. const isFreeSelected = useMemo(() => { if (!selectedPriceId || !data?.product?.prices) return false; - return Number(data.product.prices[selectedPriceId].USD) === 0; + const usd = data.product.prices[selectedPriceId].USD; + return usd === "0" || usd === "0.00"; }, [data, selectedPriceId]); const setupSubscription = async () => { @@ -159,6 +159,11 @@ export default function PageClient({ code }: { code: string }) { body: JSON.stringify({ full_code: code, price_id: selectedPriceId, quantity: quantityNumber }), }); const result = await response.json(); + + if (!response.ok) { + throw new Error(result?.error?.message ?? "Failed to setup subscription"); + } + if (!result.client_secret && !isFreeSelected) { throw new Error("Failed to setup subscription"); } diff --git a/apps/dashboard/src/components/payments/create-checkout-dialog.tsx b/apps/dashboard/src/components/payments/create-checkout-dialog.tsx index aa182ee87d..3ac9095a0a 100644 --- a/apps/dashboard/src/components/payments/create-checkout-dialog.tsx +++ b/apps/dashboard/src/components/payments/create-checkout-dialog.tsx @@ -42,6 +42,8 @@ export function CreateCheckoutDialog(props: Props) { toast({ title: "Customer type does not match expected type for this product", variant: "destructive" }); } else if (result.error instanceof KnownErrors.CustomerDoesNotExist) { toast({ title: "Customer with given customerId does not exist", variant: "destructive" }); + } else if (result.error instanceof KnownErrors.ProductAlreadyGranted) { + toast({ title: "This customer already owns the selected product", variant: "destructive" }); } else { toast({ title: "An unknown error occurred", variant: "destructive" }); }