Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 17 additions & 2 deletions apps/dashboard/src/app/(main)/purchase/[code]/page-client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -143,14 +142,29 @@ 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',
headers: { 'Content-Type': 'application/json' },
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;
Comment on lines 161 to 170
Expand Down Expand Up @@ -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>
Expand Down
14 changes: 12 additions & 2 deletions apps/dashboard/src/app/(main)/purchase/return/page-client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ type Props = {
stripeAccountId?: string,
purchaseFullCode?: string,
bypass?: string,
free?: string,
};

type ViewState =
Expand All @@ -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");
Expand All @@ -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());
Comment on lines +57 to +61
const message = `Free subscription activated. No payment required.${returnUrl ? " You will be redirected shortly." : ""}`;
setState({ kind: "success", message });
return;
}
Comment thread
nams1570 marked this conversation as resolved.
const stripe = await loadStripe(stripePublicKey, { stripeAccount: stripeAccountId });
if (!stripe) throw new Error("Stripe failed to initialize");
if (!clientSecret) return;
Expand Down Expand Up @@ -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());
Expand Down
2 changes: 2 additions & 0 deletions apps/dashboard/src/app/(main)/purchase/return/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ type Props = {
stripe_account_id?: string,
purchase_full_code?: string,
bypass?: string,
free?: string,
}>,
};

Expand All @@ -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}
/>
);
}
13 changes: 13 additions & 0 deletions apps/dashboard/src/components/payments/checkout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ type Props = {
disabled?: boolean,
onTestModeBypass?: () => Promise<void>,
chargesEnabled: boolean,
isFree: boolean,
};
Comment on lines 26 to 29

export function CheckoutForm({
Expand All @@ -35,6 +36,7 @@ export function CheckoutForm({
disabled,
onTestModeBypass,
chargesEnabled,
isFree,
}: Props) {
const stripe = useStripe();
const elements = useElements();
Expand All @@ -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
Comment thread
nams1570 marked this conversation as resolved.
// 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;
}
Comment thread
nams1570 marked this conversation as resolved.
const { error } = await stripe.confirmPayment({
elements,
clientSecret,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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" });
}
Expand Down
Loading