From 72026e61e2f7e7317f46047fced6609c87455920 Mon Sep 17 00:00:00 2001 From: Armaan Jain <84474476+Developing-Gamer@users.noreply.github.com> Date: Thu, 2 Jul 2026 11:45:43 -0700 Subject: [PATCH] Payments checkout page error display (#1704) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ## Summary by cubic Checkout now shows inline errors for validation, purchase setup, Stripe confirmation, and test‑mode bypass, with safer response parsing, inline alerts, and loading states to prevent silent failures. Free checkouts hide Stripe Elements and redirect via /purchase/return?free=1; added tests and refreshed OpenAPI for /emails/send-email to allow user_ids, all_users, or emails with recipients accepting email and not requiring user_id; docs also add a `hexclaveServerApp.queryAnalytics` tip. Written for commit 137a8c596bee6e10dcb371148c290ef3a02ae84a. Summary will update on new commits. Review in cubic ## Summary by CodeRabbit * **New Features** * Improved checkout handling for free purchases and test-mode bypasses, with clearer inline alerts and smoother redirects. * Expanded email-sending API docs to cover sending to selected users, all users, or arbitrary email addresses. * **Bug Fixes** * Checkout errors now display more reliably in the page instead of relying on generic popups. * Better handling of payment confirmation and missing payment details to reduce failed checkout edge cases. * **Documentation** * Added guidance for advanced query access and updated Hexclave reminder text. --- .../(main)/purchase/[code]/page-client.tsx | 49 ++++- .../src/components/payments/checkout.test.tsx | 204 ++++++++++++++++++ .../src/components/payments/checkout.tsx | 112 ++++++++-- 3 files changed, 343 insertions(+), 22 deletions(-) create mode 100644 apps/dashboard/src/components/payments/checkout.test.tsx 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 && }