Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/hot-jars-smell.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@clerk/clerk-js': patch
'@clerk/backend': patch
---

Update the supported API version to `2025-10-01`.
2 changes: 1 addition & 1 deletion packages/backend/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ export const API_VERSION = 'v1';

export const USER_AGENT = `${PACKAGE_NAME}@${PACKAGE_VERSION}`;
export const MAX_CACHE_LAST_UPDATED_AT_SECONDS = 5 * 60;
export const SUPPORTED_BAPI_VERSION = '2025-04-10';
export const SUPPORTED_BAPI_VERSION = '2025-10-01';

const Attributes = {
AuthToken: '__clerkAuthToken',
Expand Down
2 changes: 1 addition & 1 deletion packages/backend/src/tokens/__tests__/handshake.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -427,7 +427,7 @@ describe('HandshakeService', () => {

// Verify all required parameters are present
expect(url.searchParams.get('redirect_url')).toBeDefined();
expect(url.searchParams.get('__clerk_api_version')).toBe('2025-04-10');
expect(url.searchParams.get('__clerk_api_version')).toBe('2025-10-01');
expect(url.searchParams.get(constants.QueryParameters.SuffixedCookies)).toMatch(/^(true|false)$/);
expect(url.searchParams.get(constants.QueryParameters.HandshakeReason)).toBe('test-reason');
});
Expand Down
2 changes: 1 addition & 1 deletion packages/clerk-js/src/core/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,4 +54,4 @@ export const SIGN_UP_MODES = {
} satisfies Record<string, SignUpModes>;

// This is the currently supported version of the Frontend API
export const SUPPORTED_FAPI_VERSION = '2025-04-10';
export const SUPPORTED_FAPI_VERSION = '2025-10-01';
14 changes: 10 additions & 4 deletions packages/clerk-js/src/core/resources/BillingPlan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@ export class BillingPlan extends BaseResource implements BillingPlanResource {
id!: string;
name!: string;
fee!: BillingMoneyAmount;
annualFee!: BillingMoneyAmount;
annualMonthlyFee!: BillingMoneyAmount;
annualFee: BillingMoneyAmount | null = null;
annualMonthlyFee: BillingMoneyAmount | null = null;
description!: string;
isDefault!: boolean;
isRecurring!: boolean;
Expand All @@ -32,11 +32,17 @@ export class BillingPlan extends BaseResource implements BillingPlanResource {
return this;
}

console.log('data', {
fee: data.fee,
annual_fee: data.annual_fee,
annual_monthly_fee: data.annual_monthly_fee,
});
Comment on lines +35 to +39
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 | 🔴 Critical

Remove debug console.log before merging.

Debug logging should not be committed to production code. This will clutter browser consoles and could expose internal data structures.

Apply this diff to remove the debug statement:

-    console.log('data', {
-      fee: data.fee,
-      annual_fee: data.annual_fee,
-      annual_monthly_fee: data.annual_monthly_fee,
-    });
-
     this.id = data.id;
📝 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
console.log('data', {
fee: data.fee,
annual_fee: data.annual_fee,
annual_monthly_fee: data.annual_monthly_fee,
});
this.id = data.id;
🤖 Prompt for AI Agents
In packages/clerk-js/src/core/resources/BillingPlan.ts around lines 35 to 39,
there is a debug console.log printing the fee/annual_fee/annual_monthly_fee
object; remove this debug statement before merging (delete the console.log call
and its object) or replace it with a non-debugging, environment-gated logging
mechanism if persistent logging is required, ensuring no sensitive/internal
structures are emitted to the browser console.


this.id = data.id;
this.name = data.name;
this.fee = billingMoneyAmountFromJSON(data.fee);
this.annualFee = billingMoneyAmountFromJSON(data.annual_fee);
this.annualMonthlyFee = billingMoneyAmountFromJSON(data.annual_monthly_fee);
this.annualFee = data.annual_fee ? billingMoneyAmountFromJSON(data.annual_fee) : null;
this.annualMonthlyFee = data.annual_monthly_fee ? billingMoneyAmountFromJSON(data.annual_monthly_fee) : null;
this.description = data.description;
this.isDefault = data.is_default;
this.isRecurring = data.is_recurring;
Expand Down
10 changes: 7 additions & 3 deletions packages/clerk-js/src/ui/components/Checkout/CheckoutForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,11 @@ export const CheckoutForm = withCardStateProvider(() => {
const showPastDue = !!totals.pastDue?.amount && totals.pastDue.amount > 0;
const showDowngradeInfo = !isImmediatePlanChange;

const fee = planPeriod === 'month' ? plan.fee : plan.annualMonthlyFee;
const fee =
planPeriod === 'month'
? plan.fee
: // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
plan.annualMonthlyFee!;
Comment on lines +42 to +46
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 | 🔴 Critical

Unsafe non-null assertion could cause runtime errors.

Same issue as in PaymentAttemptPage: using plan.annualMonthlyFee! without verification could throw if the field is null.

Apply this safer pattern:

-  const fee =
-    planPeriod === 'month'
-      ? plan.fee
-      : // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
-        plan.annualMonthlyFee!;
+  const fee =
+    planPeriod === 'month' || !plan.annualMonthlyFee
+      ? plan.fee
+      : plan.annualMonthlyFee;
📝 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
const fee =
planPeriod === 'month'
? plan.fee
: // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
plan.annualMonthlyFee!;
const fee =
planPeriod === 'month' || !plan.annualMonthlyFee
? plan.fee
: plan.annualMonthlyFee;
🤖 Prompt for AI Agents
In packages/clerk-js/src/ui/components/Checkout/CheckoutForm.tsx around lines 42
to 46, the code uses an unsafe non-null assertion plan.annualMonthlyFee! which
can throw at runtime if that field is null; replace the assertion with a guarded
access and explicit handling: check whether plan.annualMonthlyFee is defined
before using it (e.g., use a conditional/fallback value or throw a descriptive
error), or compute fee via a nullish coalescing/fallback expression so fee is
never assigned an unchecked null value; ensure TypeScript types reflect the
runtime check so there are no non-null assertions left.


return (
<Drawer.Body>
Expand Down Expand Up @@ -166,10 +170,10 @@ const useCheckoutMutations = () => {
e.preventDefault();

const data = new FormData(e.currentTarget);
const paymentSourceId = data.get(HIDDEN_INPUT_NAME) as string;
const paymentMethodId = data.get(HIDDEN_INPUT_NAME) as string;

return confirmCheckout({
paymentSourceId,
paymentMethodId,
});
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -215,7 +215,10 @@ function PaymentAttemptBody({ subscriptionItem }: { subscriptionItem: BillingSub
}

const fee =
subscriptionItem.planPeriod === 'month' ? subscriptionItem.plan.fee : subscriptionItem.plan.annualMonthlyFee;
subscriptionItem.planPeriod === 'month'
? subscriptionItem.plan.fee
: // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
subscriptionItem.plan.annualMonthlyFee!;
Comment on lines 217 to +221
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 | 🔴 Critical

Unsafe non-null assertion could cause runtime errors.

The code uses a non-null assertion (plan.annualMonthlyFee!) without first verifying that annualMonthlyFee exists. Since this field is now nullable (as per the type changes), this could throw a runtime error if the API returns null.

Apply this defensive pattern instead:

-  const fee =
-    subscriptionItem.planPeriod === 'month'
-      ? subscriptionItem.plan.fee
-      : // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
-        subscriptionItem.plan.annualMonthlyFee!;
+  const fee =
+    subscriptionItem.planPeriod === 'month' || !subscriptionItem.plan.annualMonthlyFee
+      ? subscriptionItem.plan.fee
+      : subscriptionItem.plan.annualMonthlyFee;
📝 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
const fee =
subscriptionItem.planPeriod === 'month' ? subscriptionItem.plan.fee : subscriptionItem.plan.annualMonthlyFee;
subscriptionItem.planPeriod === 'month'
? subscriptionItem.plan.fee
: // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
subscriptionItem.plan.annualMonthlyFee!;
const fee =
subscriptionItem.planPeriod === 'month' || !subscriptionItem.plan.annualMonthlyFee
? subscriptionItem.plan.fee
: subscriptionItem.plan.annualMonthlyFee;
🤖 Prompt for AI Agents
In packages/clerk-js/src/ui/components/PaymentAttempts/PaymentAttemptPage.tsx
around lines 217-221, the code uses a non-null assertion on
subscriptionItem.plan.annualMonthlyFee which is now nullable; replace the unsafe
assertion with a defensive check: if annualMonthlyFee is present use it,
otherwise fall back to a safe default (e.g., plan.fee, 0, or a user-friendly
placeholder) or throw/log a clear error before using the value so no runtime
null access occurs. Ensure the code handles both month and non-month periods
without the `!` operator and preserves type-safety.


return (
<Box
Expand Down
4 changes: 2 additions & 2 deletions packages/clerk-js/src/ui/components/Plans/PlanDetails.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -227,7 +227,7 @@ const Header = React.forwardRef<HTMLDivElement, HeaderProps>((props, ref) => {
const { plan, closeSlot, planPeriod, setPlanPeriod } = props;

const fee = useMemo(() => {
if (plan.annualMonthlyFee.amount <= 0) {
if (!plan.annualMonthlyFee) {
return plan.fee;
}
return planPeriod === 'annual' ? plan.annualMonthlyFee : plan.fee;
Expand Down Expand Up @@ -333,7 +333,7 @@ const Header = React.forwardRef<HTMLDivElement, HeaderProps>((props, ref) => {
</>
</Flex>

{plan.annualMonthlyFee.amount > 0 ? (
{plan.annualMonthlyFee ? (
<Box
elementDescriptor={descriptors.planDetailPeriodToggle}
sx={t => ({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -221,18 +221,8 @@ describe('PlanDetails', () => {
currencySymbol: '$',
currency: 'USD',
},
annualFee: {
amount: 0,
amountFormatted: '0.00',
currencySymbol: '$',
currency: 'USD',
},
annualMonthlyFee: {
amount: 0,
amountFormatted: '0.00',
currencySymbol: '$',
currency: 'USD',
},
annualFee: null,
annualMonthlyFee: null,
};

const { wrapper } = await createFixtures(f => {
Expand Down Expand Up @@ -265,18 +255,8 @@ describe('PlanDetails', () => {
currencySymbol: '$',
currency: 'USD',
},
annualFee: {
amount: 0,
amountFormatted: '0.00',
currencySymbol: '$',
currency: 'USD',
},
annualMonthlyFee: {
amount: 0,
amountFormatted: '0.00',
currencySymbol: '$',
currency: 'USD',
},
annualFee: null,
annualMonthlyFee: null,
isDefault: true,
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -282,13 +282,17 @@ const CardHeader = React.forwardRef<HTMLDivElement, CardHeaderProps>((props, ref
const { plan, isCompact, planPeriod, setPlanPeriod, badge } = props;
const { name, annualMonthlyFee } = plan;

const planSupportsAnnual = annualMonthlyFee.amount > 0;
const planSupportsAnnual = Boolean(annualMonthlyFee);
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 | 🔴 Critical

🧩 Analysis chain

Verify behavior change: zero-amount annual pricing.

The logic changed from checking annualMonthlyFee.amount > 0 to Boolean(annualMonthlyFee). This means plans with an annual option but zero cost (amount: 0) will now show the annual toggle, whereas previously they would not.

Run the following script to check if any plans in the codebase have annual pricing with zero amounts:


🏁 Script executed:

#!/bin/bash
# Search for test data or fixtures with annual pricing
rg -n -A5 -B5 'annualMonthlyFee|annual_monthly_fee' --type=ts --type=json --type=tsx

Length of output: 27


I'll retry with corrected ripgrep syntax and examine the actual file:


🏁 Script executed:

#!/bin/bash
# Search for annualMonthlyFee without specifying tsx type
rg -n -A5 -B5 'annualMonthlyFee|annual_monthly_fee'

Length of output: 74162


🏁 Script executed:

#!/bin/bash
# Also examine the actual file at line 285 with context
cat packages/clerk-js/src/ui/components/PricingTable/PricingTableDefault.tsx | head -300 | tail -50

Length of output: 1579


🏁 Script executed:

#!/bin/bash
# Search for BillingMoneyAmount type definitions and mock data
rg -n 'BillingMoneyAmount|billingMoneyAmount' -g '*.ts' -g '*.tsx' -g '*.json'

Length of output: 7912


🏁 Script executed:

#!/bin/bash
# Look for test files or fixtures related to pricing
fd -e test.ts -e test.tsx -e mock.ts -e mock.tsx -e fixture.ts | xargs grep -l 'annualMonthlyFee\|annual' 2>/dev/null || echo "No test files found with pricing patterns"

Length of output: 585


🏁 Script executed:

#!/bin/bash
# Search for where pricing plans are created/mocked in the codebase
rg -n 'amount.*0|cost.*0|price.*0' packages/clerk-js/src/ui/components/PricingTable/ --type=ts

Length of output: 1428


🏁 Script executed:

#!/bin/bash
# Look at the imports and structure of the PricingTableDefault file
head -50 packages/clerk-js/src/ui/components/PricingTable/PricingTableDefault.tsx

Length of output: 1542


Fix logic to check annualMonthlyFee?.amount > 0 instead of just Boolean(annualMonthlyFee).

Line 285 changed to check for object existence instead of positive amount. This breaks plans with zero-cost annual pricing—they will now show the annual toggle when they shouldn't. The test in pricing-footer-state.spec.ts line 108 ("does not show footer when switching period if annualMonthlyFee is 0") confirms the intended behavior: plans with annualMonthlyFee: { amount: 0, ... } must not be treated as supporting annual periods.

const planSupportsAnnual = Boolean(annualMonthlyFee?.amount > 0);

Note: This pattern also appears in Plans.tsx (lines 216, 329), pricing-footer-state.ts (line 37), and PricingTableMatrix.tsx (lines 63, 159)—verify whether those require similar fixes.

🤖 Prompt for AI Agents
In packages/clerk-js/src/ui/components/PricingTable/PricingTableDefault.tsx
around line 285 the logic was changed to Boolean(annualMonthlyFee) which treats
an object with amount: 0 as truthy; update the check to explicitly test that
annualMonthlyFee?.amount > 0 so only positive annual prices enable the annual
toggle; also search and update the same pattern in Plans.tsx (around lines 216,
329), pricing-footer-state.ts (around line 37), and PricingTableMatrix.tsx
(around lines 63, 159) to use annualMonthlyFee?.amount > 0 where appropriate to
preserve the test behavior that zero-cost annual plans do not support annual
periods.


const fee = React.useMemo(() => {
if (!planSupportsAnnual) {
return plan.fee;
}
return planPeriod === 'annual' ? plan.annualMonthlyFee : plan.fee;

return planPeriod === 'annual'
? // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
plan.annualMonthlyFee!
Comment on lines +292 to +294
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 | 🟠 Major

Unsafe non-null assertion when annual pricing is enabled.

Line 294 uses plan.annualMonthlyFee! with a non-null assertion. If planSupportsAnnual is true but annualMonthlyFee is somehow null (e.g., data inconsistency), this will throw at runtime.

Consider a safer pattern:

-    return planPeriod === 'annual'
-      ? // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
-        plan.annualMonthlyFee!
-      : plan.fee;
+    if (planPeriod === 'annual' && plan.annualMonthlyFee) {
+      return plan.annualMonthlyFee;
+    }
+    return plan.fee;
🤖 Prompt for AI Agents
In packages/clerk-js/src/ui/components/PricingTable/PricingTableDefault.tsx
around lines 292 to 294, the code uses an unsafe non-null assertion
plan.annualMonthlyFee! when planPeriod === 'annual'; instead, guard against a
missing annualMonthlyFee by checking for its presence (or using a nullish
fallback) before returning it — for example, return the annualPrice if defined,
otherwise fall back to a sensible default (like plan.monthlyFee or throw/log a
descriptive error) so the UI doesn't crash on inconsistent data.

: plan.fee;
}, [planSupportsAnnual, planPeriod, plan.fee, plan.annualMonthlyFee]);

const feeFormatted = React.useMemo(() => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ export function PricingTableMatrix({

const gridTemplateColumns = React.useMemo(() => `repeat(${plans.length + 1}, minmax(9.375rem,1fr))`, [plans.length]);

const renderBillingCycleControls = React.useMemo(() => plans.some(plan => plan.annualMonthlyFee.amount > 0), [plans]);
const renderBillingCycleControls = React.useMemo(() => plans.some(plan => Boolean(plan.annualMonthlyFee)), [plans]);
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 | 🔴 Critical

Critical: Incorrect logic allows zero-cost annual plans to show billing controls.

Line 63 changed to check Boolean(plan.annualMonthlyFee) instead of verifying annualMonthlyFee.amount > 0. This means plans with zero-cost annual pricing ({amount: 0, ...}) will incorrectly display annual billing controls.

This is inconsistent with test expectations in pricing-footer-state.spec.ts which explicitly requires that zero-amount annual fees should not enable annual period support.

-  const renderBillingCycleControls = React.useMemo(() => plans.some(plan => Boolean(plan.annualMonthlyFee)), [plans]);
+  const renderBillingCycleControls = React.useMemo(
+    () => plans.some(plan => Boolean(plan.annualMonthlyFee && plan.annualMonthlyFee.amount > 0)),
+    [plans]
+  );
📝 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
const renderBillingCycleControls = React.useMemo(() => plans.some(plan => Boolean(plan.annualMonthlyFee)), [plans]);
const renderBillingCycleControls = React.useMemo(
() => plans.some(plan => Boolean(plan.annualMonthlyFee && plan.annualMonthlyFee.amount > 0)),
[plans]
);
🤖 Prompt for AI Agents
In packages/clerk-js/src/ui/components/PricingTable/PricingTableMatrix.tsx
around line 63, the billing-cycle control detection uses
Boolean(plan.annualMonthlyFee) which incorrectly treats a zero-amount annual fee
as enabled; change the predicate to check the numeric amount so only positive
annual fees enable the control (e.g., use plan.annualMonthlyFee?.amount > 0 or
equivalent null-safe check) so plans with {amount: 0} do not show annual billing
controls.


const getAllFeatures = React.useMemo(() => {
const featuresSet = new Set<string>();
Expand Down Expand Up @@ -156,12 +156,11 @@ export function PricingTableMatrix({
</Box>
{plans.map(plan => {
const highlight = plan.slug === highlightedPlan;
const planFee =
plan.annualMonthlyFee.amount <= 0
? plan.fee
: planPeriod === 'annual'
? plan.annualMonthlyFee
: plan.fee;
const planFee = !plan.annualMonthlyFee
? plan.fee
: planPeriod === 'annual'
? plan.annualMonthlyFee
: plan.fee;
Comment on lines +159 to +163
Copy link
Contributor

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion | 🟠 Major

Fee calculation should verify amount > 0 for consistency.

The fee calculation on lines 159-163 uses presence checks (!plan.annualMonthlyFee) but should also verify the amount is greater than zero to match the expected behavior across the codebase.

-                const planFee = !plan.annualMonthlyFee
+                const planFee = !plan.annualMonthlyFee || plan.annualMonthlyFee.amount === 0
                   ? plan.fee
                   : planPeriod === 'annual'
                     ? plan.annualMonthlyFee
                     : plan.fee;
🤖 Prompt for AI Agents
In packages/clerk-js/src/ui/components/PricingTable/PricingTableMatrix.tsx
around lines 159-163, the fee selection currently uses a truthy presence check
for plan.annualMonthlyFee but should ensure the amount is greater than zero;
update the ternary to explicitly check that plan.annualMonthlyFee > 0 (or
Number(plan.annualMonthlyFee) > 0) before choosing it, otherwise fall back to
plan.fee, preserving the existing planPeriod logic.


return (
<Box
Expand Down Expand Up @@ -253,7 +252,7 @@ export function PricingTableMatrix({
})}
localizationKey={localizationKeys('billing.month')}
/>
{plan.annualMonthlyFee.amount > 0 ? (
{plan.annualMonthlyFee ? (
<Box
elementDescriptor={descriptors.pricingTableMatrixFeePeriodNotice}
sx={[
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ const valueResolution = (params: UsePricingFooterStateParams): [boolean, boolean
// Active subscription
if (subscription.status === 'active') {
const isCanceled = !!subscription.canceledAt;
const isSwitchingPaidPeriod = planPeriod !== subscription.planPeriod && plan.annualMonthlyFee.amount > 0;
const isSwitchingPaidPeriod = planPeriod !== subscription.planPeriod && Boolean(plan.annualMonthlyFee);
const isActiveFreeTrial = plan.freeTrialEnabled && subscription.isFreeTrial;

if (isCanceled || isSwitchingPaidPeriod) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -262,18 +262,8 @@ describe('SubscriptionDetails', () => {
currencySymbol: '$',
currency: 'USD',
},
annualFee: {
amount: 0,
amountFormatted: '0.00',
currencySymbol: '$',
currency: 'USD',
},
annualMonthlyFee: {
amount: 0,
amountFormatted: '0.00',
currencySymbol: '$',
currency: 'USD',
},
annualFee: null,
annualMonthlyFee: null,
description: 'Free Plan description',
hasBaseFee: false,
isRecurring: true,
Expand Down
14 changes: 10 additions & 4 deletions packages/clerk-js/src/ui/components/SubscriptionDetails/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -374,7 +374,7 @@ const SubscriptionCardActions = ({ subscription }: { subscription: BillingSubscr
const canManageBilling = subscriberType === 'user' || canOrgManageBilling;

const isSwitchable =
((subscription.planPeriod === 'month' && subscription.plan.annualMonthlyFee.amount > 0) ||
((subscription.planPeriod === 'month' && Boolean(subscription.plan.annualMonthlyFee)) ||
subscription.planPeriod === 'annual') &&
subscription.status !== 'past_due';
Comment on lines +377 to 379
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 | 🔴 Critical

Inconsistent annual pricing checks could cause runtime errors.

Line 377 checks for annualMonthlyFee to determine if the plan is switchable, but lines 412-415 and 485-489 use non-null assertions on annualFee without verification. These are different fields, and one being present doesn't guarantee the other exists.

Either:

  1. Guard both fields before the switchable/fee logic executes, or
  2. Use the same field consistently throughout
  const isSwitchable =
    ((subscription.planPeriod === 'month' && Boolean(subscription.plan.annualMonthlyFee)) ||
      subscription.planPeriod === 'annual') &&
    subscription.status !== 'past_due';
+
+ // Verify annualFee exists before building actions that use it
+ if (isSwitchable && subscription.planPeriod === 'month' && !subscription.plan.annualFee) {
+   return [];
+ }

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In packages/clerk-js/src/ui/components/SubscriptionDetails/index.tsx around
lines 377-379 and 412-415 and 485-489, the code inconsistently checks
subscription.plan.annualMonthlyFee but later uses non-null assertions on
subscription.plan.annualFee; this mismatch can cause runtime errors. Update the
logic so the same field is used consistently (preferably choose one canonical
field across the component) or explicitly guard both fields before any
dereference: change the switchable/fee condition to verify both annualMonthlyFee
and annualFee exist where required, and replace non-null assertions with safe
conditional access or early returns when those fields are missing.

const isFree = isFreePlan(subscription.plan);
Expand Down Expand Up @@ -409,8 +409,10 @@ const SubscriptionCardActions = ({ subscription }: { subscription: BillingSubscr
label:
subscription.planPeriod === 'month'
? localizationKeys('billing.switchToAnnualWithAnnualPrice', {
price: normalizeFormatted(subscription.plan.annualFee.amountFormatted),
currency: subscription.plan.annualFee.currencySymbol,
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
price: normalizeFormatted(subscription.plan.annualFee!.amountFormatted),
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
currency: subscription.plan.annualFee!.currencySymbol,
Comment on lines +412 to +415
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 | 🔴 Critical

Unsafe non-null assertion in localization keys.

The non-null assertions for subscription.plan.annualFee! are unsafe. If annualFee is null, this will throw at runtime when constructing the localization string.

Since this code path only executes when isSwitchable is true (which checks for annualMonthlyFee), you should also verify annualFee exists:

+      // Ensure annualFee exists before using it
+      if (!subscription.plan.annualFee) {
+        return null;
+      }
       isSwitchable
         ? {
             label:
               subscription.planPeriod === 'month'
                 ? localizationKeys('billing.switchToAnnualWithAnnualPrice', {
-                    // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
-                    price: normalizeFormatted(subscription.plan.annualFee!.amountFormatted),
-                    // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
-                    currency: subscription.plan.annualFee!.currencySymbol,
+                    price: normalizeFormatted(subscription.plan.annualFee.amountFormatted),
+                    currency: subscription.plan.annualFee.currencySymbol,
                   })

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In packages/clerk-js/src/ui/components/SubscriptionDetails/index.tsx around
lines 412 to 415, the code uses non-null assertions on
subscription.plan.annualFee which can throw if annualFee is null; add a guard
that verifies subscription.plan.annualFee exists before accessing
amountFormatted and currencySymbol (e.g., if (!subscription.plan.annualFee)
return/skip building the localized string or provide safe fallbacks), and
replace the non-null assertions with safe access or validated locals so the
localization string is only built when annualFee is present.

})
: localizationKeys('billing.switchToMonthlyWithPrice', {
price: normalizeFormatted(subscription.plan.fee.amountFormatted),
Expand Down Expand Up @@ -480,7 +482,11 @@ const SubscriptionCardActions = ({ subscription }: { subscription: BillingSubscr
const SubscriptionCard = ({ subscription }: { subscription: BillingSubscriptionItemResource }) => {
const { t } = useLocalizations();

const fee = subscription.planPeriod === 'month' ? subscription.plan.fee : subscription.plan.annualFee;
const fee =
subscription.planPeriod === 'month'
? subscription.plan.fee
: // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
subscription.plan.annualFee!;
Comment on lines +485 to +489
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 | 🔴 Critical

Unsafe non-null assertion in fee calculation.

Using subscription.plan.annualFee! without verification could throw if the field is null.

Apply this safer pattern:

-  const fee =
-    subscription.planPeriod === 'month'
-      ? subscription.plan.fee
-      : // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
-        subscription.plan.annualFee!;
+  const fee =
+    subscription.planPeriod === 'month' || !subscription.plan.annualFee
+      ? subscription.plan.fee
+      : subscription.plan.annualFee;
🤖 Prompt for AI Agents
In packages/clerk-js/src/ui/components/SubscriptionDetails/index.tsx around
lines 485 to 489, the code uses a non-null assertion
subscription.plan.annualFee! which can throw if annualFee is null/undefined;
replace the assertion with a safe check — use optional chaining and a fallback
(e.g., subscription.plan.annualFee ?? 0 or another sensible default), or
explicitly handle the missing value (throw a descriptive error or return early).
Ensure fee is computed like: if planPeriod === 'month' use
subscription.plan.fee, otherwise use subscription.plan.annualFee ?? <fallback>
so no non-null assertion is needed.


return (
<Col
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,8 @@ export function SubscriptionsList({
}

function SubscriptionRow({ subscription, length }: { subscription: BillingSubscriptionItemResource; length: number }) {
const fee = subscription.planPeriod === 'annual' ? subscription.plan.annualFee : subscription.plan.fee;
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const fee = subscription.planPeriod === 'annual' ? subscription.plan.annualFee! : subscription.plan.fee;
Comment on lines +156 to +157
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 | 🔴 Critical

Unsafe non-null assertion for annualFee.

Using subscription.plan.annualFee! without checking if it exists first could cause runtime errors when the field is null.

Apply this safer approach:

-  // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
-  const fee = subscription.planPeriod === 'annual' ? subscription.plan.annualFee! : subscription.plan.fee;
+  const fee = subscription.planPeriod === 'annual' && subscription.plan.annualFee 
+    ? subscription.plan.annualFee 
+    : subscription.plan.fee;
📝 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
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const fee = subscription.planPeriod === 'annual' ? subscription.plan.annualFee! : subscription.plan.fee;
const fee = subscription.planPeriod === 'annual' && subscription.plan.annualFee
? subscription.plan.annualFee
: subscription.plan.fee;
🤖 Prompt for AI Agents
In packages/clerk-js/src/ui/components/Subscriptions/SubscriptionsList.tsx
around lines 156-157, the code uses a non-null assertion
subscription.plan.annualFee! which can throw if annualFee is null; change this
to safely handle absence by first checking if subscription.plan.annualFee is
defined (e.g., use optional chaining and a fallback or conditional): when
planPeriod is 'annual' use subscription.plan.annualFee ?? fallbackValue (or
handle the null case explicitly), otherwise use subscription.plan.fee; ensure
TypeScript types reflect the fallback or null-check so no non-null assertion is
used.

const { captionForSubscription } = usePlansContext();

const feeFormatted = useMemo(() => {
Expand Down
4 changes: 2 additions & 2 deletions packages/clerk-js/src/ui/contexts/components/Plans.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -213,7 +213,7 @@ export const usePlansContext = () => {
const subscription =
sub ?? (plan ? activeOrUpcomingSubscriptionWithPlanPeriod(plan, selectedPlanPeriod) : undefined);
let _selectedPlanPeriod = selectedPlanPeriod;
const isEligibleForSwitchToAnnual = (plan?.annualMonthlyFee.amount ?? 0) > 0;
const isEligibleForSwitchToAnnual = Boolean(plan?.annualMonthlyFee);

if (_selectedPlanPeriod === 'annual' && !isEligibleForSwitchToAnnual) {
_selectedPlanPeriod = 'month';
Expand Down Expand Up @@ -326,7 +326,7 @@ export const usePlansContext = () => {
clerk.__internal_openCheckout({
planId: plan.id,
// if the plan doesn't support annual, use monthly
planPeriod: planPeriod === 'annual' && plan.annualMonthlyFee.amount === 0 ? 'month' : planPeriod,
planPeriod: planPeriod === 'annual' && !plan.annualMonthlyFee ? 'month' : planPeriod,
for: subscriberType,
onSubscriptionComplete: () => {
revalidateAll();
Expand Down
8 changes: 5 additions & 3 deletions packages/clerk-js/src/utils/billing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,15 +25,17 @@ export const billingTotalsFromJSON = <T extends BillingStatementTotalsJSON | Bil
taxTotal: billingMoneyAmountFromJSON(data.tax_total),
};

if ('total_due_now' in data) {
if ('total_due_now' in data && data.total_due_now) {
totals.totalDueNow = billingMoneyAmountFromJSON(data.total_due_now);
}
if ('credit' in data) {
if ('credit' in data && data.credit) {
totals.credit = billingMoneyAmountFromJSON(data.credit);
}
if ('past_due' in data) {
if ('past_due' in data && data.past_due) {
totals.pastDue = billingMoneyAmountFromJSON(data.past_due);
}

// WHY `total_due_after_free_trial` and why `proration`

Comment on lines +38 to +39
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 | 🟡 Minor

Clarify the purpose of this comment.

The comment suggests uncertainty about total_due_after_free_trial and proration fields. If these fields are part of the API response but not being handled, document why they're excluded or create a task to implement them.

🤖 Prompt for AI Agents
In packages/clerk-js/src/utils/billing.ts around lines 38 to 39, the inline
comment "// WHY `total_due_after_free_trial` and why `proration`" is vague;
replace it with a clear explanation stating whether those fields appear in the
API response and why they are intentionally ignored (e.g., not used by current
billing flow), or if they must be supported, add a TODO with a link/ID to a
tracker ticket to implement handling. Include what the fields represent, the
decision (exclude or implement), and next steps (either remove/comment as
explained or add task to implement parsing/logic).

return totals as T extends { total_due_now: BillingMoneyAmountJSON } ? BillingCheckoutTotals : BillingStatementTotals;
};
14 changes: 7 additions & 7 deletions packages/types/src/billing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,13 +131,13 @@ export interface BillingPlanResource extends ClerkResource {
*/
fee: BillingMoneyAmount;
/**
* The annual price of the plan.
* The annual price of the plan or `null` if the plan is not annual.
*/
annualFee: BillingMoneyAmount;
annualFee: BillingMoneyAmount | null;
/**
* The effective monthly price when billed annually.
* The effective monthly price when billed annually or `null` if the plan is not annual.
*/
annualMonthlyFee: BillingMoneyAmount;
annualMonthlyFee: BillingMoneyAmount | null;
Comment on lines +134 to +140
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 | 🔴 Critical

🧩 Analysis chain

Verify that consumer code was updated per previous review findings.

The nullable type change for annualFee and annualMonthlyFee is correct, but a previous review identified specific locations with non-null assertions and unsafe Boolean checks that will break with this change.

Ensure all flagged locations have been fixed before merging:

  • 7 non-null assertion sites (e.g., SubscriptionsList.tsx:157, SubscriptionDetails/index.tsx:413,415,489)
  • 3 unsafe Boolean checks (e.g., PricingTableMatrix.tsx:63, pricing-footer-state.ts:37)

Based on learnings


🏁 Script executed:

#!/bin/bash
# Verify that non-null assertions and unsafe Boolean checks have been addressed

echo "=== Checking for remaining non-null assertions on annual fees ==="
rg -n 'annualFee!' --type=ts -C2

echo ""
echo "=== Checking for remaining non-null assertions on annualMonthlyFee ==="
rg -n 'annualMonthlyFee!' --type=ts -C2

echo ""
echo "=== Checking for unsafe Boolean checks without null guards ==="
rg -nP 'if\s*\([^)]*\.(annualFee|annualMonthlyFee)\)' --type=ts -C2

echo ""
echo "=== Checking for direct property access without null checks ==="
rg -n '\.(annualFee|annualMonthlyFee)\.(amount|amountFormatted|currency)' --type=ts -C3

Length of output: 4581


Address unsafe non-null assertions in consumer code to properly handle nullable annual fees.

The nullable type changes for annualFee and annualMonthlyFee are correct, but consumer code still uses non-null assertions (!) that bypass TypeScript's type safety. These should be replaced with proper null checks or type guards:

  • SubscriptionsList.tsx:157: subscription.plan.annualFee!
  • SubscriptionDetails/index.tsx:413, 415, 489: annualFee! and property access
  • PricingTableDefault.tsx:294: plan.annualMonthlyFee!
  • PaymentAttemptPage.tsx:221: plan.annualMonthlyFee!
  • CheckoutForm.tsx:46: plan.annualMonthlyFee!

While runtime logic (e.g., planPeriod === 'annual') ensures these values exist in practice, the type system doesn't capture these guarantees. Refactor to use discriminated unions, type predicates, or explicit null checks instead of non-null assertions.

🤖 Prompt for AI Agents
In packages/types/src/billing.ts around lines 134-140, avoid forcing non-null
with `!` in consumers by either making the Plan type a discriminated union
(e.g., separate MonthlyPlan and AnnualPlan where annualFee/annualMonthlyFee
exist only on AnnualPlan with a literal period discriminator) or keep the
nullable fields but update the listed consumer files to narrow the type before
access: replace `!` usages with explicit null checks or type guards (e.g., if
(plan.period === 'annual') { /* safe to access annualFee/annualMonthlyFee */ })
so TypeScript can verify safety and remove the non-null assertions in
SubscriptionsList.tsx:157, SubscriptionDetails/index.tsx:413,415,489,
PricingTableDefault.tsx:294, PaymentAttemptPage.tsx:221, and
CheckoutForm.tsx:46.

/**
* A short description of what the plan offers.
*/
Expand Down Expand Up @@ -688,10 +688,10 @@ export type CreateCheckoutParams = WithOptionalOrgType<{
}>;

/**
* The `confirm()` method accepts the following parameters. **Only one of `paymentSourceId`, `paymentToken`, or `useTestCard` should be provided.**
* The `confirm()` method accepts the following parameters. **Only one of `paymentMethodId`, `paymentToken`, or `useTestCard` should be provided.**
*
* @unionReturnHeadings
* ["paymentSourceId", "paymentToken", "useTestCard"]
* ["paymentMethodId", "paymentToken", "useTestCard"]
*
* @experimental This is an experimental API for the Billing feature that is available under a public beta, and the API is subject to change. It is advised to [pin](https://clerk.com/docs/pinning) the SDK version and the clerk-js version to avoid breaking changes.
*/
Expand All @@ -700,7 +700,7 @@ export type ConfirmCheckoutParams =
/**
* The ID of a saved payment method to use for this checkout.
*/
paymentSourceId?: string;
paymentMethodId?: string;
}
| {
/**
Expand Down
4 changes: 2 additions & 2 deletions packages/types/src/json.ts
Original file line number Diff line number Diff line change
Expand Up @@ -634,8 +634,8 @@ export interface BillingPlanJSON extends ClerkResourceJSON {
id: string;
name: string;
fee: BillingMoneyAmountJSON;
annual_fee: BillingMoneyAmountJSON;
annual_monthly_fee: BillingMoneyAmountJSON;
annual_fee: BillingMoneyAmountJSON | null;
annual_monthly_fee: BillingMoneyAmountJSON | null;
amount: number;
amount_formatted: string;
annual_amount: number;
Expand Down
Loading