fix: checkout flow for 0 dollar subscription (#1465)

### Context
There was a small bug via dashboard checkout flow where it would fail on
trying to create a checkout flow for a free product subscription because
no client secret is generated for a 0 dollar subscription.

### Summary of Changes
The flow should be fine now. There's special carve out logic for it.
That being said, users attempting to mimic a free plan grant are
encouraged to follow the `ensureFreePlan` pattern.


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **New Features**
* Free subscription selections now bypass Stripe payment processing,
streamlining checkout for zero-cost offerings.
* Purchase return flow now properly recognizes and activates free
subscriptions without requiring payment confirmation.

<!-- review_stack_entry_start -->

[![Review Change
Stack](https://storage.googleapis.com/coderabbit_public_assets/review-stack-in-coderabbit-ui.svg)](https://app.coderabbit.ai/change-stack/hexclave/stack-auth/pull/1465?utm_source=github_walkthrough&utm_medium=github&utm_campaign=change_stack)

<!-- review_stack_entry_end -->

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Aman Ganapathy 2026-05-21 14:33:55 -07:00 committed by GitHub
parent e3bd5a6638
commit be2ad595ad
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 46 additions and 4 deletions

View File

@ -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<yup.InferType<typeof inlineProductSchema>, "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
const isFreeSelected = useMemo<boolean>(() => {
if (!selectedPriceId || !data?.product?.prices) return false;
const usd = data.product.prices[selectedPriceId].USD;
return usd === "0" || usd === "0.00";
}, [data, selectedPriceId]);
const setupSubscription = async () => {
const response = await fetch(`${baseUrl}/payments/purchases/purchase-session`, {
method: 'POST',
@ -150,7 +159,12 @@ 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 (!response.ok) {
throw new Error(result?.error?.message ?? "Failed to setup subscription");
}
if (!result.client_secret && !isFreeSelected) {
throw new Error("Failed to setup subscription");
}
return result.client_secret;
@ -392,6 +406,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}
/>
</StripeElementsProvider>
</div>

View File

@ -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<ViewState>({ 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());

View File

@ -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}
/>
);
}

View File

@ -25,6 +25,7 @@ type Props = {
disabled?: boolean,
onTestModeBypass?: () => Promise<void>,
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,

View File

@ -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" });
}