mirror of
https://github.com/stack-auth/stack.git
synced 2026-06-04 21:04:37 +08:00
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 --> [](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:
parent
e3bd5a6638
commit
be2ad595ad
@ -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>
|
||||
|
||||
@ -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());
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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" });
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user