diff --git a/src/app/(app)/settings/billing-usage/subscription/components/subscription-submit-button.tsx b/src/app/(app)/settings/billing-usage/subscription/components/subscription-submit-button.tsx index d0657372..f0270b35 100644 --- a/src/app/(app)/settings/billing-usage/subscription/components/subscription-submit-button.tsx +++ b/src/app/(app)/settings/billing-usage/subscription/components/subscription-submit-button.tsx @@ -17,7 +17,7 @@ interface SubscriptionSubmitButtonProps { packageUpdates: PackageUpdateInput[]; /** Desired end-state for the checkout flow. */ checkoutProducts: ProductCheckoutInput[]; - /** True when a Custom Amount has an empty/invalid quantity (update flow only). */ + /** True when a Custom Amount has an empty/invalid quantity. */ hasInvalidCustom: boolean; /** * The AI spending cap to store, when the user changed it: a USD figure, or @@ -41,7 +41,8 @@ interface SubscriptionSubmitButtonProps { * * The ACTION still splits on the subscription state: * - no active paid subscription → `createCheckoutSession`, which redirects to - * Stripe. No diff gating: there is nothing to compare against. + * Stripe. No diff gating: there is nothing to compare against — but disabled + * while there is nothing to buy, and validated on click. * - active paid subscription → `updateSubscription`, a mutation that applies the * plan change in place and does NOT redirect to a payment page (an upgrade may * raise an invoice afterwards). Disabled when the selection equals the current @@ -87,30 +88,32 @@ export function SubscriptionSubmitButton({ }; if (needsCheckout) { + const handleCheckout = () => { + // Checkout has no diff to gate on, but an out-of-range quantity is still + // one: it would be sent as a plan nobody can be billed for. + if (hasInvalidCustom) { + rejectInvalidAmount(); + return; + } + if (!checkoutProducts.length) return; + // Opened from the click itself, and carried through both mutations: + // Stripe's URL only exists once the second answers, and a tab opened + // then has lost the user gesture that lets it through (see + // `openDeferredTab`). It is closed again if either step fails. + const tab = openDeferredTab(); + withAiSpendCap( + () => createCheckout.mutate({ products: checkoutProducts }, { target: tab }), + () => tab.cancel(), + ); + }; + return ( diff --git a/src/lib/relay/environment.ts b/src/lib/relay/environment.ts index 1e1d0be4..4d1b09da 100644 --- a/src/lib/relay/environment.ts +++ b/src/lib/relay/environment.ts @@ -424,6 +424,23 @@ const fetchRelay: FetchFunction = async (request, variables, cacheConfig, upload return fetchRelay(request, variables, cacheConfig, uploadables); } } + + // The server returned errors and no data. Relay would otherwise reject with + // its internal "No data returned for operation ..." message, which reaches + // the user verbatim through a mutation's `onError` toast (e.g. checkout on a + // mutation like `createCheckoutSession`, whose `CheckoutResult` payload has + // no `errors` field). Throw the readable server message so `onError` shows + // that instead. Partial responses (data present with field errors) fall + // through unchanged. + if (json.data == null) { + const message = json.errors + .map((error: { message?: string }) => error?.message) + .filter(Boolean) + .join('. '); + if (message) { + throw new Error(message); + } + } } return json;