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;