Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
45 commits
Select commit Hold shift + click to select a range
234ce35
wip
panteliselef Jun 17, 2025
2041111
wip 2
panteliselef Jun 17, 2025
e7a3f26
create the layout and functionality
panteliselef Jun 18, 2025
7af1561
display button for annual to switch to monthly as well
panteliselef Jun 18, 2025
a64ed1d
Merge branch 'refs/heads/main' into elef/com-835-split-up-plandetails…
panteliselef Jun 30, 2025
99607d7
implement new UI
panteliselef Jul 1, 2025
0af7fb9
add unit tests
panteliselef Jul 1, 2025
a2303b6
address issue with animation
panteliselef Jul 1, 2025
e0f8e1a
add more test cases
panteliselef Jul 1, 2025
1b75fe1
use box shadow instead of border
panteliselef Jul 1, 2025
a8db78f
Merge branch 'main' into elef/com-835-split-up-plandetails-and-subscr…
panteliselef Jul 1, 2025
3a0907c
remove unnecessary context
panteliselef Jul 1, 2025
1ddd06b
handle missing avatar url
panteliselef Jul 1, 2025
d84f755
add descriptors
panteliselef Jul 1, 2025
99f4342
Merge branch 'refs/heads/main' into elef/com-835-split-up-plandetails…
panteliselef Jul 2, 2025
aaf814c
add more descriptors
panteliselef Jul 2, 2025
55f1ba3
handle localizations
panteliselef Jul 2, 2025
962e8d5
fix issues from conflicts
panteliselef Jul 2, 2025
84528d0
finishing touches on subscription details
panteliselef Jul 2, 2025
dc926ab
add unit tests for PlanDetails
panteliselef Jul 2, 2025
79b3c79
remove old file
panteliselef Jul 2, 2025
958fdd9
replace experimental with internal apis
panteliselef Jul 2, 2025
c5dafc4
Update packages/types/src/clerk.ts
panteliselef Jul 2, 2025
bd9984f
address pr comments
panteliselef Jul 10, 2025
587f2b8
wip
panteliselef Jul 10, 2025
af6a28b
Revert "wip"
panteliselef Jul 13, 2025
1c161cc
address pr feedback
panteliselef Jul 13, 2025
0ed99f1
bump
panteliselef Jul 13, 2025
39100a9
wip subscription items
panteliselef Jul 13, 2025
7db88a9
Revert "wip subscription items"
panteliselef Jul 13, 2025
257a3cc
wip changeset
panteliselef Jul 13, 2025
3753dee
bundlewatch.config.json
panteliselef Jul 13, 2025
477d3f5
Merge branch 'refs/heads/main' into elef/com-835-split-up-plandetails…
panteliselef Jul 14, 2025
28d86b4
fix lint
panteliselef Jul 14, 2025
38fa5f6
fix build issue
panteliselef Jul 14, 2025
f536952
Adds past due at
panteliselef Jul 14, 2025
63b60ce
chore(clerk-js, types, localization): Handle past due subscription
panteliselef Jul 14, 2025
e010868
add unit tests
panteliselef Jul 14, 2025
0664cfd
Merge branch 'main' into elef/handle-past-due-items
panteliselef Jul 14, 2025
5a730fe
Merge branch 'main' into elef/com-1036-display-past-due-subscriptions…
panteliselef Jul 14, 2025
639e180
add changeset
panteliselef Jul 14, 2025
497df84
address rabbit feedback
panteliselef Jul 14, 2025
778349e
Update packages/localizations/src/en-US.ts
panteliselef Jul 14, 2025
4c75e78
Merge branch 'main' into elef/com-1036-display-past-due-subscriptions…
panteliselef Jul 14, 2025
522a357
update test
panteliselef Jul 14, 2025
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
7 changes: 7 additions & 0 deletions .changeset/salty-spiders-end.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@clerk/localizations': minor
'@clerk/clerk-js': minor
'@clerk/types': minor
---

Display past due subscriptions properly.
3 changes: 3 additions & 0 deletions packages/clerk-js/src/core/resources/CommerceSubscription.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export class CommerceSubscription extends BaseResource implements CommerceSubscr
planPeriod!: CommerceSubscriptionPlanPeriod;
status!: CommerceSubscriptionStatus;
createdAt!: Date;
pastDueAt!: Date | null;
periodStartDate!: Date;
periodEndDate!: Date | null;
canceledAtDate!: Date | null;
Expand Down Expand Up @@ -51,6 +52,8 @@ export class CommerceSubscription extends BaseResource implements CommerceSubscr
this.canceledAt = data.canceled_at;

this.createdAt = unixEpochToDate(data.created_at);
this.pastDueAt = data.past_due_at ? unixEpochToDate(data.past_due_at) : null;

this.periodStartDate = unixEpochToDate(data.period_start);
this.periodEndDate = data.period_end ? unixEpochToDate(data.period_end) : null;
this.canceledAtDate = data.canceled_at ? unixEpochToDate(data.canceled_at) : null;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ import { getClosestProfileScrollBox } from '@/ui/utils/getClosestProfileScrollBo
import { useProtect } from '../../common';
import { usePlansContext, usePricingTableContext, useSubscriberTypeContext } from '../../contexts';
import {
Badge,
Box,
Button,
Col,
Expand All @@ -25,6 +24,7 @@ import {
} from '../../customizables';
import { Check, Plus } from '../../icons';
import { common, InternalThemeProvider } from '../../styledSystem';
import { SubscriptionBadge } from '../Subscriptions/badge';

interface PricingTableDefaultProps {
plans?: CommercePlanResource[] | null;
Expand Down Expand Up @@ -128,7 +128,7 @@ function Card(props: CardProps) {
() => activeOrUpcomingSubscriptionBasedOnPlanPeriod(plan, planPeriod),
[plan, planPeriod, activeOrUpcomingSubscriptionBasedOnPlanPeriod],
);
const isPlanActive = subscription?.status === 'active';

const hasFeatures = plan.features.length > 0;
const showStatusRow = !!subscription;

Expand Down Expand Up @@ -186,21 +186,7 @@ function Card(props: CardProps) {
isCompact={isCompact}
planPeriod={planPeriod}
setPlanPeriod={setPlanPeriod}
badge={
showStatusRow ? (
isPlanActive ? (
<Badge
colorScheme='secondary'
localizationKey={localizationKeys('badge__activePlan')}
/>
) : (
<Badge
colorScheme='primary'
localizationKey={localizationKeys('badge__upcomingPlan')}
/>
)
) : undefined
}
badge={showStatusRow ? <SubscriptionBadge subscription={subscription} /> : undefined}
/>
<Box
elementDescriptor={descriptors.pricingTableCardBody}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -729,4 +729,74 @@ describe('SubscriptionDetails', () => {
);
});
});

it('past due subscription shows correct status and disables actions', async () => {
const { wrapper, fixtures } = await createFixtures(f => {
f.withUser({ email_addresses: ['[email protected]'] });
});

const plan = {
id: 'plan_monthly',
name: 'Monthly Plan',
amount: 1000,
amountFormatted: '10.00',
annualAmount: 9000,
annualAmountFormatted: '90.00',
annualMonthlyAmount: 750,
annualMonthlyAmountFormatted: '7.50',
currencySymbol: '$',
description: 'Monthly Plan',
hasBaseFee: true,
isRecurring: true,
currency: 'USD',
isDefault: false,
payerType: ['user'],
publiclyVisible: true,
slug: 'monthly-plan',
avatarUrl: '',
features: [],
};

fixtures.clerk.billing.getSubscriptions.mockResolvedValue({
data: [
{
id: 'sub_past_due',
plan,
createdAt: new Date('2021-01-01'),
periodStartDate: new Date('2021-01-01'),
periodEndDate: new Date('2021-02-01'),
canceledAtDate: null,
paymentSourceId: 'src_123',
planPeriod: 'month' as const,
status: 'past_due' as const,
pastDueAt: new Date('2021-01-15'),
},
],
total_count: 1,
});

const { getByRole, getByText, queryByText, queryByRole } = render(
<Drawer.Root
open
onOpenChange={() => {}}
>
<SubscriptionDetails />
</Drawer.Root>,
{ wrapper },
);

await waitFor(() => {
expect(getByRole('heading', { name: /Subscription/i })).toBeVisible();
expect(getByText('Monthly Plan')).toBeVisible();
expect(getByText('Past due')).toBeVisible();
expect(getByText('$10.00 / Month')).toBeVisible();

expect(queryByText('Subscribed on')).toBeNull();
expect(getByText('Past due on')).toBeVisible();
expect(getByText('January 15, 2021')).toBeVisible();

// Menu button should be present but disabled
expect(queryByRole('button', { name: /Open menu/i })).toBeNull();
});
});
});
52 changes: 33 additions & 19 deletions packages/clerk-js/src/ui/components/SubscriptionDetails/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,6 @@ import { LineItems } from '@/ui/elements/LineItems';
import { SubscriberTypeContext, usePlansContext, useSubscriberTypeContext, useSubscriptions } from '../../contexts';
import type { LocalizationKey } from '../../customizables';
import {
Badge,
Button,
Col,
descriptors,
Expand All @@ -39,6 +38,7 @@ import {
Text,
useLocalizations,
} from '../../customizables';
import { SubscriptionBadge } from '../Subscriptions/badge';

// We cannot derive the state of confrimation modal from the existance subscription, as it will make the animation laggy when the confimation closes.
const SubscriptionForCancellationContext = React.createContext<{
Expand Down Expand Up @@ -68,12 +68,14 @@ export const SubscriptionDetails = (props: __internal_SubscriptionDetailsProps)
type UseGuessableSubscriptionResult<Or extends 'throw' | undefined = undefined> = Or extends 'throw'
? {
upcomingSubscription?: CommerceSubscriptionResource;
activeSubscription: CommerceSubscriptionResource;
pastDueSubscription?: CommerceSubscriptionResource;
activeSubscription?: CommerceSubscriptionResource;
anySubscription: CommerceSubscriptionResource;
isLoading: boolean;
}
: {
upcomingSubscription?: CommerceSubscriptionResource;
pastDueSubscription?: CommerceSubscriptionResource;
activeSubscription?: CommerceSubscriptionResource;
anySubscription?: CommerceSubscriptionResource;
isLoading: boolean;
Expand All @@ -85,15 +87,17 @@ function useGuessableSubscription<Or extends 'throw' | undefined = undefined>(op
const { data: subscriptions, isLoading } = useSubscriptions();
const activeSubscription = subscriptions?.find(sub => sub.status === 'active');
const upcomingSubscription = subscriptions?.find(sub => sub.status === 'upcoming');
const pastDueSubscription = subscriptions?.find(sub => sub.status === 'past_due');

if (options?.or === 'throw' && !activeSubscription) {
throw new Error('No active subscription found');
if (options?.or === 'throw' && !activeSubscription && !pastDueSubscription) {
throw new Error('No active or past due subscription found');
}

return {
upcomingSubscription,
pastDueSubscription,
activeSubscription: activeSubscription as any, // Type is correct due to the throw above
anySubscription: (upcomingSubscription || activeSubscription) as any,
anySubscription: (upcomingSubscription || activeSubscription || pastDueSubscription) as any,
isLoading,
};
}
Expand All @@ -111,7 +115,7 @@ const SubscriptionDetailsInternal = (props: __internal_SubscriptionDetailsProps)
} = usePlansContext();

const { data: subscriptions, isLoading } = useSubscriptions();
const { activeSubscription } = useGuessableSubscription();
const { activeSubscription, pastDueSubscription } = useGuessableSubscription();

if (isLoading) {
return (
Expand All @@ -123,7 +127,7 @@ const SubscriptionDetailsInternal = (props: __internal_SubscriptionDetailsProps)
);
}

if (!activeSubscription) {
if (!activeSubscription && !pastDueSubscription) {
// Should never happen, since Free will always be active
return null;
}
Expand Down Expand Up @@ -200,7 +204,7 @@ const SubscriptionDetailsFooter = withCardStateProvider(() => {
}, [subscription, setError, setLoading, subscriberType, organization?.id, onSubscriptionCancel, setIsOpen, setIdle]);

// If either the active or upcoming subscription is the free plan, then a C1 cannot switch to a different period or cancel the plan
if (isFreePlan(anySubscription.plan)) {
if (isFreePlan(anySubscription.plan) || anySubscription.status === 'past_due') {
return null;
}

Expand Down Expand Up @@ -270,7 +274,9 @@ const SubscriptionDetailsFooter = withCardStateProvider(() => {
});

function SubscriptionDetailsSummary() {
const { anySubscription, activeSubscription, upcomingSubscription } = useGuessableSubscription({ or: 'throw' });
const { anySubscription, activeSubscription, upcomingSubscription } = useGuessableSubscription({
or: 'throw',
});

if (!activeSubscription) {
return null;
Expand Down Expand Up @@ -326,10 +332,11 @@ const SubscriptionCardActions = ({ subscription }: { subscription: CommerceSubsc
const canManageBilling = subscriberType === 'user' || canOrgManageBilling;

const isSwitchable =
(subscription.planPeriod === 'month' && subscription.plan.annualMonthlyAmount > 0) ||
subscription.planPeriod === 'annual';
((subscription.planPeriod === 'month' && subscription.plan.annualMonthlyAmount > 0) ||
subscription.planPeriod === 'annual') &&
subscription.status !== 'past_due';
const isFree = isFreePlan(subscription.plan);
const isCancellable = subscription.canceledAtDate === null && !isFree;
const isCancellable = subscription.canceledAtDate === null && !isFree && subscription.status !== 'past_due';
const isReSubscribable = subscription.canceledAtDate !== null && !isFree;

const openCheckout = useCallback(
Expand Down Expand Up @@ -425,7 +432,6 @@ const SubscriptionCardActions = ({ subscription }: { subscription: CommerceSubsc

// New component for individual subscription cards
const SubscriptionCard = ({ subscription }: { subscription: CommerceSubscriptionResource }) => {
const isActive = subscription.status === 'active';
const { t } = useLocalizations();

return (
Expand Down Expand Up @@ -471,10 +477,9 @@ const SubscriptionCard = ({ subscription }: { subscription: CommerceSubscription
>
{subscription.plan.name}
</Text>
<Badge
<SubscriptionBadge
subscription={subscription}
elementDescriptor={descriptors.subscriptionDetailsCardBadge}
colorScheme={isActive ? 'secondary' : 'primary'}
localizationKey={isActive ? localizationKeys('badge__activePlan') : localizationKeys('badge__upcomingPlan')}
/>
</Flex>

Expand All @@ -501,7 +506,14 @@ const SubscriptionCard = ({ subscription }: { subscription: CommerceSubscription
</Flex>
</Col>

{isActive ? (
{subscription.pastDueAt ? (
<DetailRow
label={localizationKeys('commerce.subscriptionDetails.pastDueAt')}
value={formatDate(subscription.pastDueAt)}
/>
) : null}

{subscription.status === 'active' ? (
<>
<DetailRow
label={localizationKeys('commerce.subscriptionDetails.subscribedOn')}
Expand All @@ -519,12 +531,14 @@ const SubscriptionCard = ({ subscription }: { subscription: CommerceSubscription
/>
)}
</>
) : (
) : null}

{subscription.status === 'upcoming' ? (
<DetailRow
label={localizationKeys('commerce.subscriptionDetails.beginsOn')}
value={formatDate(subscription.periodStartDate)}
/>
)}
) : null}
</Col>
);
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ import {
} from '../../contexts';
import type { LocalizationKey } from '../../customizables';
import {
Badge,
Button,
Col,
Flex,
Expand All @@ -26,6 +25,7 @@ import {
} from '../../customizables';
import { ArrowsUpDown, CogFilled, Plans, Plus } from '../../icons';
import { useRouter } from '../../router';
import { SubscriptionBadge } from './badge';

export function SubscriptionsList({
title,
Expand Down Expand Up @@ -113,17 +113,12 @@ export function SubscriptionsList({
{subscription.plan.name}
</Text>
{sortedSubscriptions.length > 1 || !!subscription.canceledAtDate ? (
<Badge
colorScheme={subscription.status === 'active' ? 'secondary' : 'primary'}
localizationKey={
subscription.status === 'active'
? localizationKeys('badge__activePlan')
: localizationKeys('badge__upcomingPlan')
}
/>
<SubscriptionBadge subscription={subscription} />
) : null}
</Flex>

{(!subscription.plan.isDefault || subscription.status === 'upcoming') && (
// here
<Text
variant='caption'
colorScheme='secondary'
Expand Down
38 changes: 38 additions & 0 deletions packages/clerk-js/src/ui/components/Subscriptions/badge.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import type { CommerceSubscriptionResource } from '@clerk/types';

import { Badge, localizationKeys } from '@/ui/customizables';
import type { ElementDescriptor } from '@/ui/customizables/elementDescriptors';

const keys = {
active: 'badge__activePlan',
upcoming: 'badge__upcomingPlan',
past_due: 'badge__pastDuePlan',
};

const colors = {
active: 'secondary',
upcoming: 'primary',
past_due: 'warning',
};

export const SubscriptionBadge = ({
subscription,
elementDescriptor,
}: {
subscription: CommerceSubscriptionResource;
elementDescriptor?: ElementDescriptor;
}) => {
return (
<Badge
elementDescriptor={elementDescriptor}
colorScheme={
// @ts-expect-error `ended` is included
colors[subscription.status]
}
localizationKey={localizationKeys(
// @ts-expect-error `ended` is included
keys[subscription.status],
)}
/>
);
};
4 changes: 4 additions & 0 deletions packages/clerk-js/src/ui/contexts/components/Plans.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -280,6 +280,10 @@ export const usePlansContext = () => {
);

const captionForSubscription = useCallback((subscription: CommerceSubscriptionResource) => {
if (subscription.pastDueAt) {
return localizationKeys('badge__pastDueAt', { date: subscription.pastDueAt });
}

if (subscription.status === 'upcoming') {
return localizationKeys('badge__startsAt', { date: subscription.periodStartDate });
}
Expand Down
Loading
Loading