diff --git a/.changeset/salty-spiders-end.md b/.changeset/salty-spiders-end.md
new file mode 100644
index 00000000000..ee790fd717d
--- /dev/null
+++ b/.changeset/salty-spiders-end.md
@@ -0,0 +1,7 @@
+---
+'@clerk/localizations': minor
+'@clerk/clerk-js': minor
+'@clerk/types': minor
+---
+
+Display past due subscriptions properly.
diff --git a/packages/clerk-js/src/core/resources/CommerceSubscription.ts b/packages/clerk-js/src/core/resources/CommerceSubscription.ts
index ac4d949fdf4..d2f8ae66219 100644
--- a/packages/clerk-js/src/core/resources/CommerceSubscription.ts
+++ b/packages/clerk-js/src/core/resources/CommerceSubscription.ts
@@ -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;
@@ -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;
diff --git a/packages/clerk-js/src/ui/components/PricingTable/PricingTableDefault.tsx b/packages/clerk-js/src/ui/components/PricingTable/PricingTableDefault.tsx
index ab99e7fe3bc..f70faa325a1 100644
--- a/packages/clerk-js/src/ui/components/PricingTable/PricingTableDefault.tsx
+++ b/packages/clerk-js/src/ui/components/PricingTable/PricingTableDefault.tsx
@@ -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,
@@ -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;
@@ -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;
@@ -186,21 +186,7 @@ function Card(props: CardProps) {
isCompact={isCompact}
planPeriod={planPeriod}
setPlanPeriod={setPlanPeriod}
- badge={
- showStatusRow ? (
- isPlanActive ? (
-
- ) : (
-
- )
- ) : undefined
- }
+ badge={showStatusRow ? : undefined}
/>
{
);
});
});
+
+ it('past due subscription shows correct status and disables actions', async () => {
+ const { wrapper, fixtures } = await createFixtures(f => {
+ f.withUser({ email_addresses: ['test@clerk.com'] });
+ });
+
+ 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(
+ {}}
+ >
+
+ ,
+ { 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();
+ });
+ });
});
diff --git a/packages/clerk-js/src/ui/components/SubscriptionDetails/index.tsx b/packages/clerk-js/src/ui/components/SubscriptionDetails/index.tsx
index 2774763171f..410f52ece60 100644
--- a/packages/clerk-js/src/ui/components/SubscriptionDetails/index.tsx
+++ b/packages/clerk-js/src/ui/components/SubscriptionDetails/index.tsx
@@ -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,
@@ -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<{
@@ -68,12 +68,14 @@ export const SubscriptionDetails = (props: __internal_SubscriptionDetailsProps)
type UseGuessableSubscriptionResult = 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;
@@ -85,15 +87,17 @@ function useGuessableSubscription(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,
};
}
@@ -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 (
@@ -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;
}
@@ -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;
}
@@ -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;
@@ -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(
@@ -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 (
@@ -471,10 +477,9 @@ const SubscriptionCard = ({ subscription }: { subscription: CommerceSubscription
>
{subscription.plan.name}
-
@@ -501,7 +506,14 @@ const SubscriptionCard = ({ subscription }: { subscription: CommerceSubscription
- {isActive ? (
+ {subscription.pastDueAt ? (
+
+ ) : null}
+
+ {subscription.status === 'active' ? (
<>
)}
>
- ) : (
+ ) : null}
+
+ {subscription.status === 'upcoming' ? (
- )}
+ ) : null}
);
};
diff --git a/packages/clerk-js/src/ui/components/Subscriptions/SubscriptionsList.tsx b/packages/clerk-js/src/ui/components/Subscriptions/SubscriptionsList.tsx
index 17d6eaff437..effb90e695b 100644
--- a/packages/clerk-js/src/ui/components/Subscriptions/SubscriptionsList.tsx
+++ b/packages/clerk-js/src/ui/components/Subscriptions/SubscriptionsList.tsx
@@ -9,7 +9,6 @@ import {
} from '../../contexts';
import type { LocalizationKey } from '../../customizables';
import {
- Badge,
Button,
Col,
Flex,
@@ -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,
@@ -113,17 +113,12 @@ export function SubscriptionsList({
{subscription.plan.name}
{sortedSubscriptions.length > 1 || !!subscription.canceledAtDate ? (
-
+
) : null}
+
{(!subscription.plan.isDefault || subscription.status === 'upcoming') && (
+ // here
{
+ return (
+
+ );
+};
diff --git a/packages/clerk-js/src/ui/contexts/components/Plans.tsx b/packages/clerk-js/src/ui/contexts/components/Plans.tsx
index 56b69be7ad4..cdda0d9d8d7 100644
--- a/packages/clerk-js/src/ui/contexts/components/Plans.tsx
+++ b/packages/clerk-js/src/ui/contexts/components/Plans.tsx
@@ -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 });
}
diff --git a/packages/localizations/src/en-US.ts b/packages/localizations/src/en-US.ts
index fa721a53773..ff8a3e46624 100644
--- a/packages/localizations/src/en-US.ts
+++ b/packages/localizations/src/en-US.ts
@@ -48,9 +48,11 @@ export const enUS: LocalizationResource = {
badge__renewsAt: "Renews {{ date | shortDate('en-US') }}",
badge__requiresAction: 'Requires action',
badge__startsAt: "Starts {{ date | shortDate('en-US') }}",
+ badge__pastDueAt: "Past due {{ date | shortDate('en-US') }}",
badge__thisDevice: 'This device',
badge__unverified: 'Unverified',
badge__upcomingPlan: 'Upcoming',
+ badge__pastDuePlan: 'Past due',
badge__userDevice: 'User device',
badge__you: 'You',
commerce: {
@@ -129,6 +131,7 @@ export const enUS: LocalizationResource = {
endsOn: 'Ends on',
renewsAt: 'Renews at',
beginsOn: 'Begins on',
+ pastDueAt: 'Past due on',
},
reSubscribe: 'Resubscribe',
seeAllFeatures: 'See all features',
diff --git a/packages/types/src/commerce.ts b/packages/types/src/commerce.ts
index 45f802c7cd5..6d4cba1744e 100644
--- a/packages/types/src/commerce.ts
+++ b/packages/types/src/commerce.ts
@@ -95,7 +95,7 @@ export type CommerceSubscriberType = 'org' | 'user';
*
* ```
*/
-export type CommerceSubscriptionStatus = 'active' | 'ended' | 'upcoming';
+export type CommerceSubscriptionStatus = 'active' | 'ended' | 'upcoming' | 'past_due';
/**
* @experimental This is an experimental API for the Billing feature that is available under a public beta, and the API is subject to change.
@@ -974,6 +974,15 @@ export interface CommerceSubscriptionResource extends ClerkResource {
* ```
*/
createdAt: Date;
+ /**
+ * @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 the SDK version and the clerk-js version to a specific version to avoid breaking changes.
+ * @example
+ * ```tsx
+ *
+ * ```
+ */
+ pastDueAt: Date | null;
/**
* @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 the SDK version and the clerk-js version to a specific version to avoid breaking changes.
diff --git a/packages/types/src/json.ts b/packages/types/src/json.ts
index 378a35cc1f4..ec37cef7cf0 100644
--- a/packages/types/src/json.ts
+++ b/packages/types/src/json.ts
@@ -793,6 +793,7 @@ export interface CommerceSubscriptionJSON extends ClerkResourceJSON {
period_start: number;
period_end: number;
canceled_at: number | null;
+ past_due_at: number | null;
}
/**
diff --git a/packages/types/src/localization.ts b/packages/types/src/localization.ts
index cf1ff595d1c..3d3cf7c701d 100644
--- a/packages/types/src/localization.ts
+++ b/packages/types/src/localization.ts
@@ -146,7 +146,9 @@ export type __internal_LocalizationResource = {
badge__currentPlan: LocalizationValue;
badge__upcomingPlan: LocalizationValue;
badge__activePlan: LocalizationValue;
+ badge__pastDuePlan: LocalizationValue;
badge__startsAt: LocalizationValue<'date'>;
+ badge__pastDueAt: LocalizationValue<'date'>;
badge__endsAt: LocalizationValue;
badge__expired: LocalizationValue;
badge__canceledEndsAt: LocalizationValue<'date'>;
@@ -207,6 +209,7 @@ export type __internal_LocalizationResource = {
endsOn: LocalizationValue;
renewsAt: LocalizationValue;
beginsOn: LocalizationValue;
+ pastDueAt: LocalizationValue;
};
monthly: LocalizationValue;
annually: LocalizationValue;