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 d3937e69d..3c3342e62 100644 --- a/apps/dashboard/src/app/(main)/purchase/[code]/page-client.tsx +++ b/apps/dashboard/src/app/(main)/purchase/[code]/page-client.tsx @@ -32,6 +32,41 @@ type ProductData = { 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(); const MAX_STRIPE_AMOUNT_CENTS = 999_999 * 100; +const GENERIC_PURCHASE_FAILURE_MESSAGE = "We couldn't complete the purchase. Please try again."; +const GENERIC_TEST_MODE_PURCHASE_FAILURE_MESSAGE = "We couldn't complete the test purchase. Please try again."; + +async function readResponseJson(response: Response): Promise { + const text = await response.text(); + if (text.length === 0) { + return null; + } + try { + return JSON.parse(text); + } catch { + return null; + } +} + +function getResponseCode(responseBody: unknown) { + if (typeof responseBody === "object" && responseBody !== null && "code" in responseBody && typeof responseBody.code === "string") { + return responseBody.code; + } + return null; +} + +function getPurchaseFailureMessage(responseBody: unknown, fallbackMessage: string) { + if (getResponseCode(responseBody) !== null && typeof responseBody === "object" && responseBody !== null && "error" in responseBody && typeof responseBody.error === "string") { + return responseBody.error; + } + return fallbackMessage; +} + +function getClientSecret(responseBody: unknown) { + if (typeof responseBody === "object" && responseBody !== null && "client_secret" in responseBody && typeof responseBody.client_secret === "string") { + return responseBody.client_secret; + } + return null; +} export default function PageClient({ code }: { code: string }) { const [data, setData] = useState(null); @@ -126,16 +161,17 @@ export default function PageClient({ code }: { code: string }) { headers: { "Content-Type": "application/json" }, body: JSON.stringify({ full_code: code, price_id: selectedPriceId, quantity: quantityNumber }), }); - const result = await response.json(); + const result = await readResponseJson(response); if (!response.ok) { - throw new Error(result?.error?.message ?? "Failed to setup subscription"); + throw new Error(getPurchaseFailureMessage(result, GENERIC_PURCHASE_FAILURE_MESSAGE)); } - if (!result.client_secret && !isFreeSelected) { - throw new Error("Failed to setup subscription"); + const clientSecret = getClientSecret(result); + if (!clientSecret && !isFreeSelected) { + throw new Error(GENERIC_PURCHASE_FAILURE_MESSAGE); } - return result.client_secret; + return clientSecret; }; const handleBypass = useCallback(async () => { @@ -152,7 +188,8 @@ export default function PageClient({ code }: { code: string }) { }), }); if (!response.ok) { - throw new Error("Failed to bypass with test mode"); + const result = await readResponseJson(response); + throw new Error(getPurchaseFailureMessage(result, GENERIC_TEST_MODE_PURCHASE_FAILURE_MESSAGE)); } const url = new URL(`/purchase/return`, window.location.origin); url.searchParams.set("bypass", "1"); diff --git a/apps/dashboard/src/components/payments/checkout.test.tsx b/apps/dashboard/src/components/payments/checkout.test.tsx new file mode 100644 index 000000000..15bd2f7a6 --- /dev/null +++ b/apps/dashboard/src/components/payments/checkout.test.tsx @@ -0,0 +1,204 @@ +// @vitest-environment jsdom + +import type { HTMLAttributes, ReactNode } from "react"; +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { CheckoutForm, TestModeBypassForm } from "./checkout"; + +const mockStripe = vi.hoisted(() => ({ + confirmPayment: vi.fn(), +})); + +const mockElements = vi.hoisted(() => ({ + submit: vi.fn(), +})); + +const alertMock = vi.hoisted(() => vi.fn()); +const locationAssignMock = vi.hoisted(() => vi.fn()); + +vi.mock("@stripe/react-stripe-js", () => ({ + PaymentElement: () =>
Payment details
, + useElements: () => mockElements, + useStripe: () => mockStripe, +})); + +vi.mock("@/components/design-components/alert", () => ({ + DesignAlert: ({ + title, + description, + className: _className, + }: { + title?: ReactNode, + description?: ReactNode, + className?: string, + }) => ( +
+ {title &&
{title}
} + {description &&
{description}
} +
+ ), +})); + +vi.mock("@/components/design-components/card", () => ({ + DesignCard: ({ children }: { children: ReactNode }) =>
{children}
, +})); + +vi.mock("@/components/ui", () => ({ + Typography: ({ children, className: _className }: { children: ReactNode } & HTMLAttributes) => ( +
{children}
+ ), +})); + +function renderCheckoutForm(props?: { + setupSubscription?: () => Promise, + isFree?: boolean, +}) { + return render( + "client-secret")} + stripeAccountId="acct_123" + fullCode="purchase-code" + disabled={false} + chargesEnabled={true} + isFree={props?.isFree ?? false} + />, + ); +} + +describe("checkout forms", () => { + beforeEach(() => { + vi.stubGlobal("alert", alertMock); + alertMock.mockReset(); + locationAssignMock.mockReset(); + Object.defineProperty(window, "location", { + configurable: true, + value: { + ...window.location, + assign: locationAssignMock, + origin: "http://localhost", + }, + }); + mockElements.submit.mockResolvedValue({}); + mockStripe.confirmPayment.mockResolvedValue({}); + }); + + afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); + mockElements.submit.mockReset(); + mockStripe.confirmPayment.mockReset(); + }); + + it("shows a blocking inline error when the test-mode bypass fails", async () => { + render( + { + throw new Error("You already have this product and cannot purchase it again."); + }} + />, + ); + + fireEvent.click(screen.getByRole("button", { name: "Complete test purchase" })); + + expect(await screen.findByRole("alert")).toBeTruthy(); + expect(screen.getByText("Could not complete test purchase")).toBeTruthy(); + expect(screen.getByText("You already have this product and cannot purchase it again.")).toBeTruthy(); + expect(alertMock).not.toHaveBeenCalled(); + }); + + it("clears a stale test-mode bypass error before retrying", async () => { + const onBypass = vi.fn() + .mockRejectedValueOnce(new Error("First failure")) + .mockResolvedValueOnce(undefined); + + render(); + + fireEvent.click(screen.getByRole("button", { name: "Complete test purchase" })); + expect(await screen.findByText("First failure")).toBeTruthy(); + + fireEvent.click(screen.getByRole("button", { name: "Complete test purchase" })); + + await waitFor(() => { + expect(screen.queryByText("First failure")).toBeNull(); + }); + expect(alertMock).not.toHaveBeenCalled(); + }); + + it("shows Stripe Elements validation errors inline", async () => { + const setupSubscription = vi.fn(async () => "client-secret"); + mockElements.submit.mockResolvedValueOnce({ + error: { + message: "Enter a complete payment method.", + }, + }); + + renderCheckoutForm({ setupSubscription }); + + fireEvent.click(screen.getByRole("button", { name: "Submit" })); + + expect(await screen.findByText("Enter a complete payment method.")).toBeTruthy(); + expect(setupSubscription).not.toHaveBeenCalled(); + expect(mockStripe.confirmPayment).not.toHaveBeenCalled(); + expect(alertMock).not.toHaveBeenCalled(); + }); + + it("shows purchase-session setup errors inline", async () => { + const setupSubscription = vi.fn(async () => { + throw new Error("New purchases are currently blocked for this project."); + }); + + renderCheckoutForm({ setupSubscription }); + + fireEvent.click(screen.getByRole("button", { name: "Submit" })); + + expect(await screen.findByText("New purchases are currently blocked for this project.")).toBeTruthy(); + expect(mockStripe.confirmPayment).not.toHaveBeenCalled(); + expect(alertMock).not.toHaveBeenCalled(); + }); + + it("shows Stripe confirmation failures inline", async () => { + mockStripe.confirmPayment.mockResolvedValueOnce({ + error: { + type: "card_error", + message: "Your card was declined.", + }, + }); + + renderCheckoutForm(); + + fireEvent.click(screen.getByRole("button", { name: "Submit" })); + + expect(await screen.findByText("Your card was declined.")).toBeTruthy(); + expect(alertMock).not.toHaveBeenCalled(); + }); + + it("skips Stripe Elements for free checkout success", async () => { + const setupSubscription = vi.fn(async () => null); + + renderCheckoutForm({ setupSubscription, isFree: true }); + + expect(screen.queryByText("Payment details")).toBeNull(); + + fireEvent.click(screen.getByRole("button", { name: "Submit" })); + + await waitFor(() => { + expect(locationAssignMock).toHaveBeenCalledWith("http://localhost/purchase/return?stripe_account_id=acct_123&purchase_full_code=purchase-code&free=1"); + }); + expect(setupSubscription).toHaveBeenCalledTimes(1); + expect(mockElements.submit).not.toHaveBeenCalled(); + expect(mockStripe.confirmPayment).not.toHaveBeenCalled(); + expect(alertMock).not.toHaveBeenCalled(); + }); + + it("treats confirmPayment without an error as success", async () => { + renderCheckoutForm(); + + fireEvent.click(screen.getByRole("button", { name: "Submit" })); + + await waitFor(() => { + expect(mockStripe.confirmPayment).toHaveBeenCalledTimes(1); + }); + expect(screen.queryByText("An unexpected error occurred.")).toBeNull(); + expect(alertMock).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/dashboard/src/components/payments/checkout.tsx b/apps/dashboard/src/components/payments/checkout.tsx index 5a20f1e3d..c48adf6c5 100644 --- a/apps/dashboard/src/components/payments/checkout.tsx +++ b/apps/dashboard/src/components/payments/checkout.tsx @@ -1,6 +1,7 @@ "use client"; import { DesignButton } from "@/components/design-components/button"; +import { DesignAlert } from "@/components/design-components/alert"; import { DesignCard } from "@/components/design-components/card"; import { Typography } from "@/components/ui"; import { @@ -8,7 +9,8 @@ import { useElements, useStripe, } from "@stripe/react-stripe-js"; -import { StripeError, StripePaymentElementOptions } from "@stripe/stripe-js"; +import { Result } from "@hexclave/shared/dist/utils/results"; +import { StripePaymentElementOptions } from "@stripe/stripe-js"; import { FlaskIcon, WarningCircleIcon } from "@phosphor-icons/react"; import { useState } from "react"; @@ -22,8 +24,29 @@ const paymentElementOptions = { }, } satisfies StripePaymentElementOptions; +function getErrorMessage(error: unknown, fallbackMessage: string) { + if (error instanceof Error && error.message) { + return error.message; + } + return fallbackMessage; +} + +function getStripeConfirmationError(result: unknown) { + if (typeof result !== "object" || result === null || !("error" in result)) { + return null; + } + const error = result.error; + if (typeof error !== "object" || error === null) { + return null; + } + return { + type: "type" in error && typeof error.type === "string" ? error.type : null, + message: "message" in error && typeof error.message === "string" ? error.message : null, + }; +} + type Props = { - setupSubscription: () => Promise, + setupSubscription: () => Promise, stripeAccountId: string, fullCode: string, returnUrl?: string, @@ -59,6 +82,19 @@ export function TestModeBypassForm({ onBypass: () => Promise, disabled?: boolean, }) { + const [bypassError, setBypassError] = useState(null); + const [isCompleting, setIsCompleting] = useState(false); + + const handleBypass = async () => { + setBypassError(null); + setIsCompleting(true); + const result = await Result.fromPromise(onBypass()); + if (result.status === "error") { + setBypassError(getErrorMessage(result.error, "We couldn't complete the test purchase. Please try again.")); + } + setIsCompleting(false); + }; + return (
@@ -75,12 +111,21 @@ export function TestModeBypassForm({
Complete test purchase + {bypassError && ( + + )}
); } @@ -99,15 +144,11 @@ export function CheckoutForm({ const [message, setMessage] = useState(null); const handleSubmit = async () => { - if (!stripe || !elements) { + if (!isFree && (!stripe || !elements)) { return; } - const { error: submitError } = await elements.submit(); - if (submitError) { - return setMessage(submitError.message ?? "An unexpected error occurred."); - } + setMessage(null); - const clientSecret = await setupSubscription(); const stripeReturnUrl = new URL(`/purchase/return`, window.location.origin); stripeReturnUrl.searchParams.set("stripe_account_id", stripeAccountId); stripeReturnUrl.searchParams.set("purchase_full_code", fullCode); @@ -116,6 +157,11 @@ export function CheckoutForm({ } if (isFree) { + const setupResult = await Result.fromPromise(setupSubscription()); + if (setupResult.status === "error") { + setMessage(getErrorMessage(setupResult.error, "We couldn't complete the purchase. Please try again.")); + return; + } // $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 @@ -126,15 +172,49 @@ export function CheckoutForm({ window.location.assign(stripeReturnUrl.toString()); return; } - const { error } = await stripe.confirmPayment({ - elements, + + if (!stripe || !elements) { + return; + } + const activeStripe = stripe; + const activeElements = elements; + + const submitResult = await Result.fromPromise(activeElements.submit()); + if (submitResult.status === "error") { + setMessage(getErrorMessage(submitResult.error, "An unexpected error occurred.")); + return; + } + const { error: submitError } = submitResult.data; + if (submitError) { + setMessage(submitError.message ?? "An unexpected error occurred."); + return; + } + + const setupResult = await Result.fromPromise(setupSubscription()); + if (setupResult.status === "error") { + setMessage(getErrorMessage(setupResult.error, "We couldn't complete the purchase. Please try again.")); + return; + } + const clientSecret = setupResult.data; + + if (clientSecret == null) { + setMessage("We couldn't complete the purchase. Please try again."); + return; + } + const confirmResult = await Result.fromPromise(activeStripe.confirmPayment({ + elements: activeElements, clientSecret, confirmParams: { return_url: stripeReturnUrl.toString(), }, - }) as { error?: StripeError }; + })); + if (confirmResult.status === "error") { + setMessage(getErrorMessage(confirmResult.error, "An unexpected error occurred.")); + return; + } + const error = getStripeConfirmationError(confirmResult.data); - if (!error) { + if (error == null) { return; } if (error.type === "card_error" || error.type === "validation_error") { @@ -150,9 +230,9 @@ export function CheckoutForm({ return ( - + {!isFree && }