Skip to content
5 changes: 5 additions & 0 deletions .changeset/itchy-keys-shake.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@clerk/testing': patch
---

Bug fix: Toggling the period switch would not match the requested period `startCheckout({ period })`.
5 changes: 5 additions & 0 deletions .changeset/rotten-ghosts-build.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@clerk/clerk-js': patch
---

Use error metadata for invalid change plan screen on `Checkout` component.
6 changes: 6 additions & 0 deletions .changeset/sad-lines-share.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@clerk/shared': patch
'@clerk/types': patch
---

Parse partial `plan` in `ClerkAPIError.meta`
33 changes: 33 additions & 0 deletions integration/tests/pricing-table.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -316,5 +316,38 @@ testAgainstRunningApps({ withEnv: [appConfigs.envs.withBilling] })('pricing tabl

await fakeUser.deleteIfExists();
});

test('displays notice then plan cannot change', async ({ page, context }) => {
const u = createTestUtils({ app, page, context });

const fakeUser = u.services.users.createFakeUser();
await u.services.users.createBapiUser(fakeUser);

await u.po.signIn.goTo();
await u.po.signIn.signInWithEmailAndInstantPassword({ email: fakeUser.email, password: fakeUser.password });
await u.po.page.goToRelative('/user');

await u.po.userProfile.waitForMounted();
await u.po.userProfile.switchToBillingTab();
await u.po.page.getByRole('button', { name: 'Switch plans' }).click();
await u.po.pricingTable.startCheckout({ planSlug: 'plus', period: 'annually' });
await u.po.checkout.waitForMounted();
await u.po.checkout.fillTestCard();
await u.po.checkout.clickPayOrSubscribe();
await expect(u.po.page.getByText('Payment was successful!')).toBeVisible();

await u.po.checkout.confirmAndContinue();
await u.po.pricingTable.startCheckout({ planSlug: 'pro', shouldSwitch: true, period: 'monthly' });
await u.po.checkout.waitForMounted();
await expect(
page
.locator('.cl-checkout-root')
.getByText(
'You cannot subscribe to this plan by paying monthly. To subscribe to this plan, you need to choose to pay annually',
),
).toBeVisible();

await fakeUser.deleteIfExists();
});
});
});
2 changes: 1 addition & 1 deletion packages/clerk-js/bundlewatch.config.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"files": [
{ "path": "./dist/clerk.js", "maxSize": "605kB" },
{ "path": "./dist/clerk.browser.js", "maxSize": "69.2KB" },
{ "path": "./dist/clerk.browser.js", "maxSize": "69.3KB" },
{ "path": "./dist/clerk.legacy.browser.js", "maxSize": "113KB" },
{ "path": "./dist/clerk.headless*.js", "maxSize": "53KB" },
{ "path": "./dist/ui-common*.js", "maxSize": "106.3KB" },
Expand Down
20 changes: 6 additions & 14 deletions packages/clerk-js/src/ui/components/Checkout/CheckoutPage.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { useClerk, useOrganization, useUser } from '@clerk/shared/react';
import type { ClerkAPIError, CommerceCheckoutResource, CommercePlanResource } from '@clerk/types';
import type { ClerkAPIError, CommerceCheckoutResource } from '@clerk/types';
import { createContext, useContext, useEffect, useMemo } from 'react';
import useSWR from 'swr';
import useSWRMutation from 'swr/mutation';

import { useCheckoutContext, usePlans } from '../../contexts';
import { useCheckoutContext } from '../../contexts';

type CheckoutStatus = 'pending' | 'ready' | 'completed' | 'missing_payer_email' | 'invalid_plan_change' | 'error';

Expand All @@ -14,7 +14,6 @@ const CheckoutContextRoot = createContext<{
updateCheckout: (checkout: CommerceCheckoutResource) => void;
errors: ClerkAPIError[];
startCheckout: () => void;
plan: CommercePlanResource | undefined;
status: CheckoutStatus;
} | null>(null);

Expand Down Expand Up @@ -88,16 +87,10 @@ const useCheckoutCreator = () => {
};

const Root = ({ children }: { children: React.ReactNode }) => {
const { planId } = useCheckoutContext();
const { data: plans, isLoading: plansLoading } = usePlans();
const { checkout, isMutating, updateCheckout, errors, startCheckout } = useCheckoutCreator();

const plan = plans?.find(p => p.id === planId);

const isLoading = isMutating || plansLoading;

const status = useMemo(() => {
if (isLoading) return 'pending';
if (isMutating) return 'pending';
const completedCode = 'completed';
if (checkout?.status === completedCode) return completedCode;
if (checkout) return 'ready';
Expand All @@ -106,19 +99,18 @@ const Root = ({ children }: { children: React.ReactNode }) => {
const isMissingPayerEmail = !!errors?.some((e: ClerkAPIError) => e.code === missingCode);
if (isMissingPayerEmail) return missingCode;
const invalidChangeCode = 'invalid_plan_change';
if (errors?.[0]?.code === invalidChangeCode && plan) return invalidChangeCode;
if (errors?.[0]?.code === invalidChangeCode) return invalidChangeCode;
return 'error';
}, [isLoading, errors, checkout, plan?.id, checkout?.status]);
}, [isMutating, errors, checkout, checkout?.status]);

return (
<CheckoutContextRoot.Provider
value={{
checkout,
isLoading,
isLoading: isMutating,
updateCheckout,
errors,
startCheckout,
plan,
status,
}}
>
Expand Down
4 changes: 2 additions & 2 deletions packages/clerk-js/src/ui/components/Checkout/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { Flow, localizationKeys, Spinner } from '../../customizables';
import { CheckoutComplete } from './CheckoutComplete';
import { CheckoutForm } from './CheckoutForm';
import * as CheckoutPage from './CheckoutPage';
import { AddEmailForm, GenericError, InvalidPlanError } from './parts';
import { AddEmailForm, GenericError, InvalidPlanScreen } from './parts';

export const Checkout = (props: __internal_CheckoutProps) => {
return (
Expand Down Expand Up @@ -40,7 +40,7 @@ export const Checkout = (props: __internal_CheckoutProps) => {
</CheckoutPage.Stage>

<CheckoutPage.Stage name='invalid_plan_change'>
<InvalidPlanError />
<InvalidPlanScreen />
</CheckoutPage.Stage>

<CheckoutPage.Stage name='missing_payer_email'>
Expand Down
18 changes: 13 additions & 5 deletions packages/clerk-js/src/ui/components/Checkout/parts.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { useMemo } from 'react';

import { Alert } from '@/ui/elements/Alert';
import { Drawer, useDrawerContext } from '@/ui/elements/Drawer';
import { LineItems } from '@/ui/elements/LineItems';
Expand Down Expand Up @@ -34,11 +36,17 @@ export const GenericError = () => {
);
};

export const InvalidPlanError = () => {
const { plan } = useCheckoutContextRoot();
export const InvalidPlanScreen = () => {
const { errors } = useCheckoutContextRoot();

const planFromError = useMemo(() => {
const error = errors?.find(e => e.code === 'invalid_plan_change');
return error?.meta?.plan;
}, [errors]);

const { planPeriod } = useCheckoutContext();

if (!plan) {
if (!planFromError) {
return null;
}

Expand All @@ -60,12 +68,12 @@ export const InvalidPlanError = () => {
<LineItems.Root>
<LineItems.Group>
<LineItems.Title
title={plan.name}
title={planFromError.name}
description={planPeriod === 'annual' ? localizationKeys('commerce.billedAnnually') : undefined}
/>
<LineItems.Description
prefix={planPeriod === 'annual' ? 'x12' : undefined}
text={`${plan.currencySymbol}${planPeriod === 'month' ? plan.amountFormatted : plan.annualMonthlyAmountFormatted}`}
text={`${planFromError.currency_symbol}${planPeriod === 'month' ? planFromError.amount_formatted : planFromError.annual_monthly_amount_formatted}`}
suffix={localizationKeys('commerce.checkout.perMonth')}
/>
Comment on lines +71 to 78
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Missing null-guards for optional plan fields

planFromError.currency_symbol, amount_formatted, annual_monthly_amount_formatted are all optional according to the API typings.
If the backend omits any of them the component will crash during render.

Guard or provide fallbacks, e.g.

- text={`${planFromError.currency_symbol}${planPeriod === 'month' ? planFromError.amount_formatted : planFromError.annual_monthly_amount_formatted}`}
+ text={`${planFromError?.currency_symbol ?? ''}${planPeriod === 'month'
+   ? planFromError?.amount_formatted ?? '--'
+   : planFromError?.annual_monthly_amount_formatted ?? '--'}`}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
title={planFromError.name}
description={planPeriod === 'annual' ? localizationKeys('commerce.billedAnnually') : undefined}
/>
<LineItems.Description
prefix={planPeriod === 'annual' ? 'x12' : undefined}
text={`${plan.currencySymbol}${planPeriod === 'month' ? plan.amountFormatted : plan.annualMonthlyAmountFormatted}`}
text={`${planFromError.currency_symbol}${planPeriod === 'month' ? planFromError.amount_formatted : planFromError.annual_monthly_amount_formatted}`}
suffix={localizationKeys('commerce.checkout.perMonth')}
/>
title={planFromError.name}
description={planPeriod === 'annual' ? localizationKeys('commerce.billedAnnually') : undefined}
/>
<LineItems.Description
prefix={planPeriod === 'annual' ? 'x12' : undefined}
text={`${planFromError?.currency_symbol ?? ''}${planPeriod === 'month'
? planFromError?.amount_formatted ?? '--'
: planFromError?.annual_monthly_amount_formatted ?? '--'}`}
suffix={localizationKeys('commerce.checkout.perMonth')}
/>
🤖 Prompt for AI Agents
In packages/clerk-js/src/ui/components/Checkout/parts.tsx around lines 74 to 81,
the properties planFromError.currency_symbol, amount_formatted, and
annual_monthly_amount_formatted are optional and may be undefined, which can
cause the component to crash during render. Add null-guards or provide default
fallback values (such as empty strings) when accessing these fields to ensure
the component handles missing data gracefully without crashing.

</LineItems.Group>
Expand Down
2 changes: 2 additions & 0 deletions packages/shared/src/error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ export function parseError(error: ClerkAPIErrorJSON): ClerkAPIError {
emailAddresses: error?.meta?.email_addresses,
identifiers: error?.meta?.identifiers,
zxcvbn: error?.meta?.zxcvbn,
plan: error?.meta?.plan,
},
};
}
Expand All @@ -110,6 +111,7 @@ export function errorToJSON(error: ClerkAPIError | null): ClerkAPIErrorJSON {
email_addresses: error?.meta?.emailAddresses,
identifiers: error?.meta?.identifiers,
zxcvbn: error?.meta?.zxcvbn,
plan: error?.meta?.plan,
},
};
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,27 +1,66 @@
import type { EnhancedPage } from './app';
import { common } from './common';

type BillingPeriod = 'monthly' | 'annually';

export const createPricingTablePageObject = (testArgs: { page: EnhancedPage }) => {
const { page } = testArgs;

const locators = {
toggle: (planSlug: string) => page.locator(`.cl-pricingTableCard__${planSlug} .cl-pricingTableCardPeriodToggle`),
indicator: (planSlug: string) => page.locator(`.cl-pricingTableCard__${planSlug} .cl-switchIndicator`),
badge: (planSlug: string) => page.locator(`.cl-pricingTableCard__${planSlug} .cl-badge`),
footer: (planSlug: string) => page.locator(`.cl-pricingTableCard__${planSlug} .cl-pricingTableCardFooter`),
};

const ensurePricingPeriod = async (planSlug: string, period: BillingPeriod): Promise<void> => {
async function waitForAttribute(selector: string, attribute: string, value: string, timeout = 5000) {
return page
.waitForFunction(
({ sel, attr, val }) => {
const element = document.querySelector(sel);
return element?.getAttribute(attr) === val;
},
{ sel: selector, attr: attribute, val: value },
{ timeout },
)
.then(() => {
return true;
})
.catch(() => {
return false;
});
}

const isAnnually = await waitForAttribute(
`.cl-pricingTableCard__${planSlug} .cl-switchIndicator`,
'data-checked',
'true',
500,
);

if (isAnnually && period === 'monthly') {
await locators.toggle(planSlug).click();
}

if (!isAnnually && period === 'annually') {
await locators.toggle(planSlug).click();
}
};

const self = {
...common(testArgs),
waitForMounted: (selector = '.cl-pricingTable-root') => {
return page.waitForSelector(selector, { state: 'attached' });
},
// clickManageSubscription: async () => {
// await page.getByText('Manage subscription').click();
// },
clickResubscribe: async () => {
await page.getByText('Re-subscribe').click();
},
waitToBeActive: async ({ planSlug }: { planSlug: string }) => {
return page
.locator(`.cl-pricingTableCard__${planSlug} .cl-badge`)
.getByText('Active')
.waitFor({ state: 'visible' });
return locators.badge(planSlug).getByText('Active').waitFor({ state: 'visible' });
},
getPlanCardCTA: ({ planSlug }: { planSlug: string }) => {
return page.locator(`.cl-pricingTableCard__${planSlug} .cl-pricingTableCardFooter`).getByRole('button', {
return locators.footer(planSlug).getByRole('button', {
name: /get|switch|subscribe/i,
});
},
Expand All @@ -32,25 +71,17 @@ export const createPricingTablePageObject = (testArgs: { page: EnhancedPage }) =
}: {
planSlug: string;
shouldSwitch?: boolean;
period?: 'monthly' | 'annually';
period?: BillingPeriod;
}) => {
const targetButtonName =
shouldSwitch === true ? 'Switch to this plan' : shouldSwitch === false ? /subscribe/i : /get|switch|subscribe/i;

if (period) {
await page.locator(`.cl-pricingTableCard__${planSlug} .cl-pricingTableCardPeriodToggle`).click();

const billedAnnuallyChecked = await page
.locator(`.cl-pricingTableCard__${planSlug} .cl-switchIndicator`)
.getAttribute('data-checked');

if (billedAnnuallyChecked === 'true' && period === 'monthly') {
await page.locator(`.cl-pricingTableCard__${planSlug} .cl-pricingTableCardPeriodToggle`).click();
}
await ensurePricingPeriod(planSlug, period);
}

await page
.locator(`.cl-pricingTableCard__${planSlug} .cl-pricingTableCardFooter`)
await locators
.footer(planSlug)
.getByRole('button', {
name: targetButtonName,
})
Expand Down
7 changes: 7 additions & 0 deletions packages/types/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,13 @@ export interface ClerkAPIError {
}[];
};
permissions?: string[];
plan?: {
amount_formatted: string;
annual_monthly_amount_formatted: string;
currency_symbol: string;
id: string;
name: string;
};
};
}

Expand Down
7 changes: 7 additions & 0 deletions packages/types/src/json.ts
Original file line number Diff line number Diff line change
Expand Up @@ -349,6 +349,13 @@ export interface ClerkAPIErrorJSON {
message: string;
}[];
};
plan?: {
amount_formatted: string;
annual_monthly_amount_formatted: string;
currency_symbol: string;
id: string;
name: string;
};
};
}

Expand Down
Loading