Skip to content

Commit cdde181

Browse files
feat(ui): show management button for plans without base fee (#8375)
1 parent f5ed902 commit cdde181

4 files changed

Lines changed: 34 additions & 15 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@clerk/ui": patch
3+
---
4+
5+
Fix the Manage Subscription button in `<UserProfile />` / `<OrganizationProfile />` and the Cancel / Re-subscribe actions in `<SubscriptionDetails />` so they are shown for paid seat-based plans that have no base fee. A shared `isManageableSubscriptionItem` helper now drives both places, treating "free / unmanageable" as "the instance's default plan" instead of "the plan has no base fee".

packages/ui/src/components/SubscriptionDetails/index.tsx

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,13 @@ import { __internal_useOrganizationBase, useClerk } from '@clerk/shared/react';
22
import type {
33
__internal_CheckoutProps,
44
__internal_SubscriptionDetailsProps,
5-
BillingPlanResource,
65
BillingSubscriptionItemResource,
76
} from '@clerk/shared/types';
87
import * as React from 'react';
98
import { useCallback, useContext, useState } from 'react';
109

1110
import { Users } from '@/icons';
11+
import { common } from '@/styledSystem';
1212
import { useProtect } from '@/ui/common/Gate';
1313
import {
1414
SubscriptionDetailsContext,
@@ -19,6 +19,7 @@ import { CardAlert } from '@/ui/elements/Card/CardAlert';
1919
import { useCardState, withCardStateProvider } from '@/ui/elements/contexts';
2020
import { Drawer, useDrawerContext } from '@/ui/elements/Drawer';
2121
import { LineItems } from '@/ui/elements/LineItems';
22+
import { isManageableSubscriptionItem } from '@/ui/utils/billingSubscription';
2223
import { handleError } from '@/ui/utils/errorHandler';
2324
import { formatDate } from '@/ui/utils/formatDate';
2425

@@ -44,9 +45,6 @@ import {
4445
useLocalizations,
4546
} from '../../customizables';
4647
import { SubscriptionBadge } from '../Subscriptions/badge';
47-
import { common } from '@/styledSystem';
48-
49-
const isFreePlan = (plan: BillingPlanResource) => !plan.hasBaseFee;
5048

5149
// We cannot derive the state of confirmation modal from the existence subscription, as it will make the animation laggy when the confirmation closes.
5250
const SubscriptionForCancellationContext = React.createContext<{
@@ -376,14 +374,14 @@ const SubscriptionCardActions = ({ subscription }: { subscription: BillingSubscr
376374
const canOrgManageBilling = useProtect(has => has({ permission: 'org:sys_billing:manage' }));
377375
const canManageBilling = subscriberType === 'user' || canOrgManageBilling;
378376

377+
const isManageable = isManageableSubscriptionItem(subscription);
379378
const isSwitchable =
380379
((subscription.planPeriod === 'month' && Boolean(subscription.plan.annualMonthlyFee)) ||
381380
(subscription.planPeriod === 'annual' && Boolean(subscription.plan.fee))) &&
382381
subscription.status !== 'past_due' &&
383-
!subscription.plan.isDefault;
384-
const isFree = isFreePlan(subscription.plan);
385-
const isCancellable = subscription.canceledAt === null && !isFree;
386-
const isReSubscribable = subscription.canceledAt !== null && !isFree;
382+
isManageable;
383+
const isCancellable = subscription.canceledAt === null && isManageable;
384+
const isReSubscribable = subscription.canceledAt !== null && isManageable;
387385

388386
const openCheckout = useCallback(
389387
(params?: __internal_CheckoutProps) => {

packages/ui/src/components/Subscriptions/SubscriptionsList.tsx

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
1-
import type { BillingPlanResource, BillingSubscriptionItemResource } from '@clerk/shared/types';
1+
import type { BillingSubscriptionItemResource } from '@clerk/shared/types';
22
import { Fragment, useMemo } from 'react';
33

44
import { useProtect } from '@/ui/common/Gate';
55
import { ProfileSection } from '@/ui/elements/Section';
66
import { common } from '@/ui/styledSystem';
7+
import { isManageableSubscriptionItem } from '@/ui/utils/billingSubscription';
78

89
import {
910
normalizeFormatted,
@@ -19,8 +20,6 @@ import { ArrowsUpDown, CogFilled, Plans, Plus, Users } from '../../icons';
1920
import { useRouter } from '../../router';
2021
import { SubscriptionBadge } from './badge';
2122

22-
const isFreePlan = (plan: BillingPlanResource) => !plan.hasBaseFee;
23-
2423
export function SubscriptionsList({
2524
title,
2625
switchPlansLabel,
@@ -45,11 +44,12 @@ export function SubscriptionsList({
4544
(commerceSettings.billing.user.hasPaidPlans && subscriberType === 'user') ||
4645
(commerceSettings.billing.organization.hasPaidPlans && subscriberType === 'organization');
4746

48-
const hasActiveFreePlan = useMemo(() => {
49-
return subscriptionItems.some(sub => isFreePlan(sub.plan) && sub.status === 'active');
50-
}, [subscriptionItems]);
47+
const hasManageableSubscription = useMemo(
48+
() => subscriptionItems.some(isManageableSubscriptionItem),
49+
[subscriptionItems],
50+
);
5151

52-
const isManageButtonVisible = canManageBilling && !hasActiveFreePlan && subscriptionItems.length > 0;
52+
const isManageButtonVisible = canManageBilling && hasManageableSubscription;
5353

5454
const sortedSubscriptionItems = useMemo(
5555
() =>
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import type { BillingSubscriptionItemResource } from '@clerk/shared/types';
2+
3+
/**
4+
* Returns `true` when a subscription item exposes at least one management
5+
* action to the user (switch period, cancel, or re-subscribe).
6+
*
7+
* The only subscription a user cannot act on is the one backed by the
8+
* instance's default plan, because every user is implicitly subscribed to it
9+
* and cannot opt out.
10+
*
11+
* Intentionally not based on `plan.hasBaseFee`: a plan can have no base fee
12+
* and still be a real paid subscription (e.g seat-based billing where the
13+
* cost is driven entirely by a seat unit price).
14+
*/
15+
export const isManageableSubscriptionItem = (subscriptionItem: BillingSubscriptionItemResource): boolean =>
16+
!subscriptionItem.plan.isDefault;

0 commit comments

Comments
 (0)