Repository: clerk/javascript
Pull request #5867: fix(clerk-js): Handle invalid_plan_change error with custom UI
Author: login: octoper
Labels: name: clerk-js
Created: 2025-05-06T19:10:36Z | Merged: 2025-05-06T19:35:28Z

## Description

When trying to switch from an annual plan to a monthly plan that has lower price FAPI returns an error, this PR handles that error and displays a custom UI

BEFORE:

![CleanShot 2025-05-06 at 22 09 36@2x](https://github.com/user-attachments/assets/c09a5040-7ea6-489a-93b9-418a24b1972e)

AFTER:

<img width="437" alt="image" src="https://github.com/user-attachments/assets/154a06f7-756c-4eae-9931-33db6c5bc148" />

<!-- Fixes #(issue number) -->

## Checklist

- [ ] `pnpm test` runs as expected.
- [ ] `pnpm build` runs as expected.
- [ ] (If applicable) [JSDoc comments](https://jsdoc.app/about-getting-started.html) have been added or updated for any package exports
- [ ] (If applicable) [Documentation](https://github.com/clerk/clerk-docs) has been updated

## Type of change

- [ ] 🐛 Bug fix
- [ ] 🌟 New feature
- [ ] 🔨 Breaking change
- [ ] 📖 Refactoring / dependency upgrade / documentation
- [ ] other:



Diff:
diff --git a/.changeset/pink-bats-share.md b/.changeset/pink-bats-share.md
new file mode 100644
index 00000000000..59d849b4f3d
--- /dev/null
+++ b/.changeset/pink-bats-share.md
@@ -0,0 +1,5 @@
+---
+'@clerk/clerk-js': patch
+---
+
+Handle `invalid_plan_change` error with custom UI
diff --git a/packages/clerk-js/bundlewatch.config.json b/packages/clerk-js/bundlewatch.config.json
index abdac25a556..245d15a9f9e 100644
--- a/packages/clerk-js/bundlewatch.config.json
+++ b/packages/clerk-js/bundlewatch.config.json
@@ -21,7 +21,7 @@
     { "path": "./dist/waitlist*.js", "maxSize": "1.3KB" },
     { "path": "./dist/keylessPrompt*.js", "maxSize": "6.5KB" },
     { "path": "./dist/pricingTable*.js", "maxSize": "4.02KB" },
-    { "path": "./dist/checkout*.js", "maxSize": "5.79KB" },
+    { "path": "./dist/checkout*.js", "maxSize": "5.95KB" },
     { "path": "./dist/paymentSources*.js", "maxSize": "9.06KB" },
     { "path": "./dist/up-billing-page*.js", "maxSize": "3.0KB" },
     { "path": "./dist/op-billing-page*.js", "maxSize": "3.0KB" },
diff --git a/packages/clerk-js/src/ui/components/Checkout/CheckoutPage.tsx b/packages/clerk-js/src/ui/components/Checkout/CheckoutPage.tsx
index 7f34075a05e..be494dd6867 100644
--- a/packages/clerk-js/src/ui/components/Checkout/CheckoutPage.tsx
+++ b/packages/clerk-js/src/ui/components/Checkout/CheckoutPage.tsx
@@ -1,8 +1,17 @@
 import type { __internal_CheckoutProps, ClerkAPIError, CommerceCheckoutResource } from '@clerk/types';
 import { useEffect } from 'react';
 
-import { Alert, Box, Flex, localizationKeys, Spinner, useAppearance, useLocalizations } from '../../customizables';
-import { Drawer, useDrawerContext } from '../../elements';
+import { usePlans } from '../../contexts';
+import {
+  Box,
+  descriptors,
+  Flex,
+  localizationKeys,
+  Spinner,
+  useAppearance,
+  useLocalizations,
+} from '../../customizables';
+import { Alert, Drawer, LineItems, useDrawerContext } from '../../elements';
 import { useCheckout, usePrefersReducedMotion } from '../../hooks';
 import { EmailForm } from '../UserProfile/EmailForm';
 import { CheckoutComplete } from './CheckoutComplete';
@@ -15,6 +24,7 @@ export const CheckoutPage = (props: __internal_CheckoutProps) => {
   const prefersReducedMotion = usePrefersReducedMotion();
   const { animations: layoutAnimations } = useAppearance().parsedLayout;
   const isMotionSafe = !prefersReducedMotion && layoutAnimations === true;
+  const { data: plans, isLoading: plansLoading } = usePlans();
 
   const { checkout, isLoading, invalidate, revalidate, updateCheckout, errors } = useCheckout({
     planId,
@@ -24,6 +34,8 @@ export const CheckoutPage = (props: __internal_CheckoutProps) => {
 
   const isMissingPayerEmail = !!errors?.some((e: ClerkAPIError) => e.code === 'missing_payer_email');
 
+  const plan = plans?.find(p => p.id === planId);
+
   const onCheckoutComplete = (newCheckout: CommerceCheckoutResource) => {
     invalidate(); // invalidate the initial checkout on complete
     updateCheckout(newCheckout);
@@ -36,7 +48,7 @@ export const CheckoutPage = (props: __internal_CheckoutProps) => {
     }
   }, [isOpen]);
 
-  if (isLoading) {
+  if (isLoading || plansLoading) {
     return (
       <Spinner
         sx={{
@@ -84,6 +96,50 @@ export const CheckoutPage = (props: __internal_CheckoutProps) => {
     );
   }
 
+  const error = errors?.[0];
+  if (error?.code === 'invalid_plan_change' && plan) {
+    return (
+      <Drawer.Body>
+        <Flex
+          gap={4}
+          direction='col'
+        >
+          <Box
+            elementDescriptor={descriptors.checkoutFormLineItemsRoot}
+            sx={t => ({
+              padding: t.space.$4,
+              borderBottomWidth: t.borderWidths.$normal,
+              borderBottomStyle: t.borderStyles.$solid,
+              borderBottomColor: t.colors.$neutralAlpha100,
+            })}
+          >
+            <LineItems.Root>
+              <LineItems.Group>
+                <LineItems.Title
+                  title={plan.name}
+                  description={planPeriod === 'annual' ? localizationKeys('commerce.billedAnnually') : undefined}
+                />
+                <LineItems.Description
+                  prefix={planPeriod === 'annual' ? 'x12' : undefined}
+                  text={`${plan.currencySymbol}${planPeriod === 'month' ? plan.amountFormatted : plan.annualMonthlyAmountFormatted}`}
+                  suffix={localizationKeys('commerce.checkout.perMonth')}
+                />
+              </LineItems.Group>
+            </LineItems.Root>
+          </Box>
+          <Box sx={t => ({ padding: t.space.$4 })}>
+            {/* TODO(@Commerce): needs localization */}
+            <Alert
+              variant='info'
+              colorScheme='info'
+              title={`You cannot subscribe to this plan by paying monthly. To subscribe to this plan, you need to choose to pay annually.`}
+            />
+          </Box>
+        </Flex>
+      </Drawer.Body>
+    );
+  }
+
   return (
     <Drawer.Body>
       <Flex
@@ -95,7 +151,10 @@ export const CheckoutPage = (props: __internal_CheckoutProps) => {
           fontSize: t.fontSizes.$md,
         })}
       >
-        <Alert colorScheme='danger'>
+        <Alert
+          variant='danger'
+          colorScheme='danger'
+        >
           {errors ? translateError(errors[0]) : 'There was a problem, please try again later.'}
         </Alert>
       </Flex>
diff --git a/packages/clerk-js/src/ui/contexts/components/Plans.tsx b/packages/clerk-js/src/ui/contexts/components/Plans.tsx
index abac46240ba..d5efbb2d0fc 100644
--- a/packages/clerk-js/src/ui/contexts/components/Plans.tsx
+++ b/packages/clerk-js/src/ui/contexts/components/Plans.tsx
@@ -31,8 +31,20 @@ export const useSubscriptions = (subscriberType?: CommerceSubscriberType) => {
   );
 };
 
-export const PlansContextProvider = ({ children }: PropsWithChildren) => {
+export const usePlans = (subscriberType?: CommerceSubscriberType) => {
   const { billing } = useClerk();
+
+  return useFetch(
+    billing.getPlans,
+    {
+      subscriberType,
+    },
+    undefined,
+    'commerce-plans',
+  );
+};
+
+export const PlansContextProvider = ({ children }: PropsWithChildren) => {
   const { organization } = useOrganization();
   const { user, isSignedIn } = useUser();
   const subscriberType = useSubscriberTypeContext();
@@ -44,11 +56,7 @@ export const PlansContextProvider = ({ children }: PropsWithChildren) => {
     revalidate: revalidateSubscriptions,
   } = useSubscriptions(subscriberType);
 
-  const {
-    data: plans,
-    isLoading: isLoadingPlans,
-    revalidate: revalidatePlans,
-  } = useFetch(billing.getPlans, { subscriberType }, undefined, 'commerce-plans');
+  const { data: plans, isLoading: isLoadingPlans, revalidate: revalidatePlans } = usePlans(subscriberType);
 
   // Revalidates the next time the hooks gets mounted
   const { revalidate: revalidateStatements } = useFetch(


Reviews:
user: login: panteliselef
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-06T19:25:46Z
user: login: octoper
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-06T19:27:48Z
user: login: octoper
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-06T19:28:12Z

Review comments:
diff_hunk: @@ -84,6 +96,51 @@ export const CheckoutPage = (props: __internal_CheckoutProps) => {
     );
   }
 
+  const error = errors?.[0];
+  if (error?.code === 'invalid_plan_change' && plan) {
+    return (
+      <Drawer.Body>
+        <Flex
+          gap={4}
+          direction='col'
+        >
+          <Box
+            elementDescriptor={descriptors.checkoutFormLineItemsRoot}
+            sx={t => ({
+              padding: t.space.$4,
+              borderBottomWidth: t.borderWidths.$normal,
+              borderBottomStyle: t.borderStyles.$solid,
+              borderBottomColor: t.colors.$neutralAlpha100,
+            })}
+          >
+            <LineItems.Root>
+              <LineItems.Group>
+                <LineItems.Title
+                  title={plan.name}
+                  description={planPeriod === 'annual' ? localizationKeys('commerce.billedAnnually') : undefined}
+                />
+                {/* TODO(@Commerce): needs localization */}
path: packages/clerk-js/src/ui/components/Checkout/CheckoutPage.tsx
user: login: panteliselef
body: Are we still missing localization keys ? If yes this is ok for now, but if these are the only ones, we should localize them.
created_at: 2025-05-06T19:24:33Z
author_association: CONTRIBUTOR
diff_hunk: @@ -84,6 +96,51 @@ export const CheckoutPage = (props: __internal_CheckoutProps) => {
     );
   }
 
+  const error = errors?.[0];
+  if (error?.code === 'invalid_plan_change' && plan) {
+    return (
+      <Drawer.Body>
+        <Flex
+          gap={4}
+          direction='col'
+        >
+          <Box
+            elementDescriptor={descriptors.checkoutFormLineItemsRoot}
+            sx={t => ({
+              padding: t.space.$4,
+              borderBottomWidth: t.borderWidths.$normal,
+              borderBottomStyle: t.borderStyles.$solid,
+              borderBottomColor: t.colors.$neutralAlpha100,
+            })}
+          >
+            <LineItems.Root>
+              <LineItems.Group>
+                <LineItems.Title
+                  title={plan.name}
+                  description={planPeriod === 'annual' ? localizationKeys('commerce.billedAnnually') : undefined}
+                />
+                {/* TODO(@Commerce): needs localization */}
path: packages/clerk-js/src/ui/components/Checkout/CheckoutPage.tsx
user: login: octoper
body: These should exists actually, let me remove the comment
created_at: 2025-05-06T19:27:48Z
author_association: MEMBER
diff_hunk: @@ -84,6 +96,51 @@ export const CheckoutPage = (props: __internal_CheckoutProps) => {
     );
   }
 
+  const error = errors?.[0];
+  if (error?.code === 'invalid_plan_change' && plan) {
+    return (
+      <Drawer.Body>
+        <Flex
+          gap={4}
+          direction='col'
+        >
+          <Box
+            elementDescriptor={descriptors.checkoutFormLineItemsRoot}
+            sx={t => ({
+              padding: t.space.$4,
+              borderBottomWidth: t.borderWidths.$normal,
+              borderBottomStyle: t.borderStyles.$solid,
+              borderBottomColor: t.colors.$neutralAlpha100,
+            })}
+          >
+            <LineItems.Root>
+              <LineItems.Group>
+                <LineItems.Title
+                  title={plan.name}
+                  description={planPeriod === 'annual' ? localizationKeys('commerce.billedAnnually') : undefined}
+                />
+                {/* TODO(@Commerce): needs localization */}
path: packages/clerk-js/src/ui/components/Checkout/CheckoutPage.tsx
user: login: octoper
body: ```suggestion
```
created_at: 2025-05-06T19:28:12Z
author_association: MEMBER

PR comments:
user: login: changeset-bot[bot]
created_at: 2025-05-06T19:10:41Z
body: ###  🦋  Changeset detected

Latest commit: 160d88c7078bdfaff26812420884c2567192997a

**The changes in this PR will be included in the next version bump.**

<details><summary>This PR includes changesets to release 3 packages</summary>

  | Name                    | Type  |
| ----------------------- | ----- |
| @clerk/clerk-js         | Patch |
| @clerk/chrome-extension | Patch |
| @clerk/clerk-expo       | Patch |

</details>

Not sure what this means? [Click here  to learn what changesets are](https://github.com/changesets/changesets/blob/main/docs/adding-a-changeset.md).

[Click here if you're a maintainer who wants to add another changeset to this PR](https://github.com/clerk/javascript/new/vaggelis/com-750-update-checkout-design-when-you-cannot-checkout?filename=.changeset/hungry-melons-speak.md&value=---%0A%22%40clerk%2Fclerk-js%22%3A%20patch%0A---%0A%0Afix(clerk-js)%3A%20Handle%20invalid_plan_change%20error%20with%20custom%20UI%0A)


author_association: NONE
user: login: vercel[bot]
created_at: 2025-05-06T19:10:42Z
body: [vc]: #X0N9w0T2HSLd0thNZbEBeSNJH45Hv9WFl1YzSo7lAOM=:eyJpc01vbm9yZXBvIjp0cnVlLCJ0eXBlIjoiZ2l0aHViIiwicHJvamVjdHMiOlt7Im5hbWUiOiJjbGVyay1qcy1zYW5kYm94IiwibGl2ZUZlZWRiYWNrIjp7InJlc29sdmVkIjowLCJ1bnJlc29sdmVkIjowLCJ0b3RhbCI6MCwibGluayI6ImNsZXJrLWpzLXNhbmRib3gtZ2l0LXZhZ2dlbGlzLWNvbS03NTAtdXBkYXRlLWNoZWNrb3V0LWRlLThkMzdiNC5jbGVya3N0YWdlLmRldiJ9LCJpbnNwZWN0b3JVcmwiOiJodHRwczovL3ZlcmNlbC5jb20vY2xlcmstcHJvZHVjdGlvbi9jbGVyay1qcy1zYW5kYm94LzQ3M2ZoREZDQU1nOW5temlFa0c2cDJ3RVdFMmciLCJwcmV2aWV3VXJsIjoiY2xlcmstanMtc2FuZGJveC1naXQtdmFnZ2VsaXMtY29tLTc1MC11cGRhdGUtY2hlY2tvdXQtZGUtOGQzN2I0LmNsZXJrc3RhZ2UuZGV2IiwibmV4dENvbW1pdFN0YXR1cyI6IkRFUExPWUVEIiwicm9vdERpcmVjdG9yeSI6InBhY2thZ2VzL2NsZXJrLWpzIn1dfQ==
**The latest updates on your projects**. Learn more about [Vercel for Git ↗︎](https://vercel.link/github-learn-more)

| Name | Status | Preview | Comments | Updated (UTC) |
| :--- | :----- | :------ | :------- | :------ |
| **clerk-js-sandbox** | ✅ Ready ([Inspect](https://vercel.com/clerk-production/clerk-js-sandbox/473fhDFCAMg9nmziEkG6p2wEWE2g)) | [Visit Preview](https://clerk-js-sandbox-git-vaggelis-com-750-update-checkout-de-8d37b4.clerkstage.dev) | 💬 [**Add feedback**](https://vercel.live/open-feedback/clerk-js-sandbox-git-vaggelis-com-750-update-checkout-de-8d37b4.clerkstage.dev?via=pr-comment-feedback-link) | May 6, 2025 7:29pm |


author_association: NONE