Skip to content

Commit dedf487

Browse files
fix(clerk-js): Display past due subscriptions properly (#6309)
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
1 parent 78cc949 commit dedf487

File tree

12 files changed

+179
-46
lines changed

12 files changed

+179
-46
lines changed

.changeset/salty-spiders-end.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'@clerk/localizations': minor
3+
'@clerk/clerk-js': minor
4+
'@clerk/types': minor
5+
---
6+
7+
Display past due subscriptions properly.

packages/clerk-js/src/core/resources/CommerceSubscription.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ export class CommerceSubscription extends BaseResource implements CommerceSubscr
2020
planPeriod!: CommerceSubscriptionPlanPeriod;
2121
status!: CommerceSubscriptionStatus;
2222
createdAt!: Date;
23+
pastDueAt!: Date | null;
2324
periodStartDate!: Date;
2425
periodEndDate!: Date | null;
2526
canceledAtDate!: Date | null;
@@ -51,6 +52,8 @@ export class CommerceSubscription extends BaseResource implements CommerceSubscr
5152
this.canceledAt = data.canceled_at;
5253

5354
this.createdAt = unixEpochToDate(data.created_at);
55+
this.pastDueAt = data.past_due_at ? unixEpochToDate(data.past_due_at) : null;
56+
5457
this.periodStartDate = unixEpochToDate(data.period_start);
5558
this.periodEndDate = data.period_end ? unixEpochToDate(data.period_end) : null;
5659
this.canceledAtDate = data.canceled_at ? unixEpochToDate(data.canceled_at) : null;

packages/clerk-js/src/ui/components/PricingTable/PricingTableDefault.tsx

Lines changed: 3 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@ import { getClosestProfileScrollBox } from '@/ui/utils/getClosestProfileScrollBo
1010
import { useProtect } from '../../common';
1111
import { usePlansContext, usePricingTableContext, useSubscriberTypeContext } from '../../contexts';
1212
import {
13-
Badge,
1413
Box,
1514
Button,
1615
Col,
@@ -25,6 +24,7 @@ import {
2524
} from '../../customizables';
2625
import { Check, Plus } from '../../icons';
2726
import { common, InternalThemeProvider } from '../../styledSystem';
27+
import { SubscriptionBadge } from '../Subscriptions/badge';
2828

2929
interface PricingTableDefaultProps {
3030
plans?: CommercePlanResource[] | null;
@@ -128,7 +128,7 @@ function Card(props: CardProps) {
128128
() => activeOrUpcomingSubscriptionBasedOnPlanPeriod(plan, planPeriod),
129129
[plan, planPeriod, activeOrUpcomingSubscriptionBasedOnPlanPeriod],
130130
);
131-
const isPlanActive = subscription?.status === 'active';
131+
132132
const hasFeatures = plan.features.length > 0;
133133
const showStatusRow = !!subscription;
134134

@@ -186,21 +186,7 @@ function Card(props: CardProps) {
186186
isCompact={isCompact}
187187
planPeriod={planPeriod}
188188
setPlanPeriod={setPlanPeriod}
189-
badge={
190-
showStatusRow ? (
191-
isPlanActive ? (
192-
<Badge
193-
colorScheme='secondary'
194-
localizationKey={localizationKeys('badge__activePlan')}
195-
/>
196-
) : (
197-
<Badge
198-
colorScheme='primary'
199-
localizationKey={localizationKeys('badge__upcomingPlan')}
200-
/>
201-
)
202-
) : undefined
203-
}
189+
badge={showStatusRow ? <SubscriptionBadge subscription={subscription} /> : undefined}
204190
/>
205191
<Box
206192
elementDescriptor={descriptors.pricingTableCardBody}

packages/clerk-js/src/ui/components/SubscriptionDetails/__tests__/SubscriptionDetails.test.tsx

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -729,4 +729,74 @@ describe('SubscriptionDetails', () => {
729729
);
730730
});
731731
});
732+
733+
it('past due subscription shows correct status and disables actions', async () => {
734+
const { wrapper, fixtures } = await createFixtures(f => {
735+
f.withUser({ email_addresses: ['[email protected]'] });
736+
});
737+
738+
const plan = {
739+
id: 'plan_monthly',
740+
name: 'Monthly Plan',
741+
amount: 1000,
742+
amountFormatted: '10.00',
743+
annualAmount: 9000,
744+
annualAmountFormatted: '90.00',
745+
annualMonthlyAmount: 750,
746+
annualMonthlyAmountFormatted: '7.50',
747+
currencySymbol: '$',
748+
description: 'Monthly Plan',
749+
hasBaseFee: true,
750+
isRecurring: true,
751+
currency: 'USD',
752+
isDefault: false,
753+
payerType: ['user'],
754+
publiclyVisible: true,
755+
slug: 'monthly-plan',
756+
avatarUrl: '',
757+
features: [],
758+
};
759+
760+
fixtures.clerk.billing.getSubscriptions.mockResolvedValue({
761+
data: [
762+
{
763+
id: 'sub_past_due',
764+
plan,
765+
createdAt: new Date('2021-01-01'),
766+
periodStartDate: new Date('2021-01-01'),
767+
periodEndDate: new Date('2021-02-01'),
768+
canceledAtDate: null,
769+
paymentSourceId: 'src_123',
770+
planPeriod: 'month' as const,
771+
status: 'past_due' as const,
772+
pastDueAt: new Date('2021-01-15'),
773+
},
774+
],
775+
total_count: 1,
776+
});
777+
778+
const { getByRole, getByText, queryByText, queryByRole } = render(
779+
<Drawer.Root
780+
open
781+
onOpenChange={() => {}}
782+
>
783+
<SubscriptionDetails />
784+
</Drawer.Root>,
785+
{ wrapper },
786+
);
787+
788+
await waitFor(() => {
789+
expect(getByRole('heading', { name: /Subscription/i })).toBeVisible();
790+
expect(getByText('Monthly Plan')).toBeVisible();
791+
expect(getByText('Past due')).toBeVisible();
792+
expect(getByText('$10.00 / Month')).toBeVisible();
793+
794+
expect(queryByText('Subscribed on')).toBeNull();
795+
expect(getByText('Past due on')).toBeVisible();
796+
expect(getByText('January 15, 2021')).toBeVisible();
797+
798+
// Menu button should be present but disabled
799+
expect(queryByRole('button', { name: /Open menu/i })).toBeNull();
800+
});
801+
});
732802
});

packages/clerk-js/src/ui/components/SubscriptionDetails/index.tsx

Lines changed: 33 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,6 @@ import { LineItems } from '@/ui/elements/LineItems';
2828
import { SubscriberTypeContext, usePlansContext, useSubscriberTypeContext, useSubscriptions } from '../../contexts';
2929
import type { LocalizationKey } from '../../customizables';
3030
import {
31-
Badge,
3231
Button,
3332
Col,
3433
descriptors,
@@ -39,6 +38,7 @@ import {
3938
Text,
4039
useLocalizations,
4140
} from '../../customizables';
41+
import { SubscriptionBadge } from '../Subscriptions/badge';
4242

4343
// We cannot derive the state of confrimation modal from the existance subscription, as it will make the animation laggy when the confimation closes.
4444
const SubscriptionForCancellationContext = React.createContext<{
@@ -68,12 +68,14 @@ export const SubscriptionDetails = (props: __internal_SubscriptionDetailsProps)
6868
type UseGuessableSubscriptionResult<Or extends 'throw' | undefined = undefined> = Or extends 'throw'
6969
? {
7070
upcomingSubscription?: CommerceSubscriptionResource;
71-
activeSubscription: CommerceSubscriptionResource;
71+
pastDueSubscription?: CommerceSubscriptionResource;
72+
activeSubscription?: CommerceSubscriptionResource;
7273
anySubscription: CommerceSubscriptionResource;
7374
isLoading: boolean;
7475
}
7576
: {
7677
upcomingSubscription?: CommerceSubscriptionResource;
78+
pastDueSubscription?: CommerceSubscriptionResource;
7779
activeSubscription?: CommerceSubscriptionResource;
7880
anySubscription?: CommerceSubscriptionResource;
7981
isLoading: boolean;
@@ -85,15 +87,17 @@ function useGuessableSubscription<Or extends 'throw' | undefined = undefined>(op
8587
const { data: subscriptions, isLoading } = useSubscriptions();
8688
const activeSubscription = subscriptions?.find(sub => sub.status === 'active');
8789
const upcomingSubscription = subscriptions?.find(sub => sub.status === 'upcoming');
90+
const pastDueSubscription = subscriptions?.find(sub => sub.status === 'past_due');
8891

89-
if (options?.or === 'throw' && !activeSubscription) {
90-
throw new Error('No active subscription found');
92+
if (options?.or === 'throw' && !activeSubscription && !pastDueSubscription) {
93+
throw new Error('No active or past due subscription found');
9194
}
9295

9396
return {
9497
upcomingSubscription,
98+
pastDueSubscription,
9599
activeSubscription: activeSubscription as any, // Type is correct due to the throw above
96-
anySubscription: (upcomingSubscription || activeSubscription) as any,
100+
anySubscription: (upcomingSubscription || activeSubscription || pastDueSubscription) as any,
97101
isLoading,
98102
};
99103
}
@@ -111,7 +115,7 @@ const SubscriptionDetailsInternal = (props: __internal_SubscriptionDetailsProps)
111115
} = usePlansContext();
112116

113117
const { data: subscriptions, isLoading } = useSubscriptions();
114-
const { activeSubscription } = useGuessableSubscription();
118+
const { activeSubscription, pastDueSubscription } = useGuessableSubscription();
115119

116120
if (isLoading) {
117121
return (
@@ -123,7 +127,7 @@ const SubscriptionDetailsInternal = (props: __internal_SubscriptionDetailsProps)
123127
);
124128
}
125129

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

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

@@ -270,7 +274,9 @@ const SubscriptionDetailsFooter = withCardStateProvider(() => {
270274
});
271275

272276
function SubscriptionDetailsSummary() {
273-
const { anySubscription, activeSubscription, upcomingSubscription } = useGuessableSubscription({ or: 'throw' });
277+
const { anySubscription, activeSubscription, upcomingSubscription } = useGuessableSubscription({
278+
or: 'throw',
279+
});
274280

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

328334
const isSwitchable =
329-
(subscription.planPeriod === 'month' && subscription.plan.annualMonthlyAmount > 0) ||
330-
subscription.planPeriod === 'annual';
335+
((subscription.planPeriod === 'month' && subscription.plan.annualMonthlyAmount > 0) ||
336+
subscription.planPeriod === 'annual') &&
337+
subscription.status !== 'past_due';
331338
const isFree = isFreePlan(subscription.plan);
332-
const isCancellable = subscription.canceledAtDate === null && !isFree;
339+
const isCancellable = subscription.canceledAtDate === null && !isFree && subscription.status !== 'past_due';
333340
const isReSubscribable = subscription.canceledAtDate !== null && !isFree;
334341

335342
const openCheckout = useCallback(
@@ -425,7 +432,6 @@ const SubscriptionCardActions = ({ subscription }: { subscription: CommerceSubsc
425432

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

431437
return (
@@ -471,10 +477,9 @@ const SubscriptionCard = ({ subscription }: { subscription: CommerceSubscription
471477
>
472478
{subscription.plan.name}
473479
</Text>
474-
<Badge
480+
<SubscriptionBadge
481+
subscription={subscription}
475482
elementDescriptor={descriptors.subscriptionDetailsCardBadge}
476-
colorScheme={isActive ? 'secondary' : 'primary'}
477-
localizationKey={isActive ? localizationKeys('badge__activePlan') : localizationKeys('badge__upcomingPlan')}
478483
/>
479484
</Flex>
480485

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

504-
{isActive ? (
509+
{subscription.pastDueAt ? (
510+
<DetailRow
511+
label={localizationKeys('commerce.subscriptionDetails.pastDueAt')}
512+
value={formatDate(subscription.pastDueAt)}
513+
/>
514+
) : null}
515+
516+
{subscription.status === 'active' ? (
505517
<>
506518
<DetailRow
507519
label={localizationKeys('commerce.subscriptionDetails.subscribedOn')}
@@ -519,12 +531,14 @@ const SubscriptionCard = ({ subscription }: { subscription: CommerceSubscription
519531
/>
520532
)}
521533
</>
522-
) : (
534+
) : null}
535+
536+
{subscription.status === 'upcoming' ? (
523537
<DetailRow
524538
label={localizationKeys('commerce.subscriptionDetails.beginsOn')}
525539
value={formatDate(subscription.periodStartDate)}
526540
/>
527-
)}
541+
) : null}
528542
</Col>
529543
);
530544
};

packages/clerk-js/src/ui/components/Subscriptions/SubscriptionsList.tsx

Lines changed: 4 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@ import {
99
} from '../../contexts';
1010
import type { LocalizationKey } from '../../customizables';
1111
import {
12-
Badge,
1312
Button,
1413
Col,
1514
Flex,
@@ -26,6 +25,7 @@ import {
2625
} from '../../customizables';
2726
import { ArrowsUpDown, CogFilled, Plans, Plus } from '../../icons';
2827
import { useRouter } from '../../router';
28+
import { SubscriptionBadge } from './badge';
2929

3030
export function SubscriptionsList({
3131
title,
@@ -113,17 +113,12 @@ export function SubscriptionsList({
113113
{subscription.plan.name}
114114
</Text>
115115
{sortedSubscriptions.length > 1 || !!subscription.canceledAtDate ? (
116-
<Badge
117-
colorScheme={subscription.status === 'active' ? 'secondary' : 'primary'}
118-
localizationKey={
119-
subscription.status === 'active'
120-
? localizationKeys('badge__activePlan')
121-
: localizationKeys('badge__upcomingPlan')
122-
}
123-
/>
116+
<SubscriptionBadge subscription={subscription} />
124117
) : null}
125118
</Flex>
119+
126120
{(!subscription.plan.isDefault || subscription.status === 'upcoming') && (
121+
// here
127122
<Text
128123
variant='caption'
129124
colorScheme='secondary'
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import type { CommerceSubscriptionResource } from '@clerk/types';
2+
3+
import { Badge, localizationKeys } from '@/ui/customizables';
4+
import type { ElementDescriptor } from '@/ui/customizables/elementDescriptors';
5+
6+
const keys = {
7+
active: 'badge__activePlan',
8+
upcoming: 'badge__upcomingPlan',
9+
past_due: 'badge__pastDuePlan',
10+
};
11+
12+
const colors = {
13+
active: 'secondary',
14+
upcoming: 'primary',
15+
past_due: 'warning',
16+
};
17+
18+
export const SubscriptionBadge = ({
19+
subscription,
20+
elementDescriptor,
21+
}: {
22+
subscription: CommerceSubscriptionResource;
23+
elementDescriptor?: ElementDescriptor;
24+
}) => {
25+
return (
26+
<Badge
27+
elementDescriptor={elementDescriptor}
28+
colorScheme={
29+
// @ts-expect-error `ended` is included
30+
colors[subscription.status]
31+
}
32+
localizationKey={localizationKeys(
33+
// @ts-expect-error `ended` is included
34+
keys[subscription.status],
35+
)}
36+
/>
37+
);
38+
};

packages/clerk-js/src/ui/contexts/components/Plans.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -280,6 +280,10 @@ export const usePlansContext = () => {
280280
);
281281

282282
const captionForSubscription = useCallback((subscription: CommerceSubscriptionResource) => {
283+
if (subscription.pastDueAt) {
284+
return localizationKeys('badge__pastDueAt', { date: subscription.pastDueAt });
285+
}
286+
283287
if (subscription.status === 'upcoming') {
284288
return localizationKeys('badge__startsAt', { date: subscription.periodStartDate });
285289
}

0 commit comments

Comments
 (0)