Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
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
6 changes: 6 additions & 0 deletions .changeset/sour-lemons-talk.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@clerk/clerk-js': minor
'@clerk/types': minor
---

Update billing resources with trial properties.
7 changes: 7 additions & 0 deletions .changeset/tender-planets-win.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
---

Update PricingTable with trial info.
4 changes: 4 additions & 0 deletions packages/clerk-js/src/core/resources/CommercePlan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ export class CommercePlan extends BaseResource implements CommercePlanResource {
slug!: string;
avatarUrl!: string;
features!: CommerceFeature[];
freeTrialDays!: number | null;
freeTrialEnabled!: boolean;

constructor(data: CommercePlanJSON) {
super();
Expand Down Expand Up @@ -56,6 +58,8 @@ export class CommercePlan extends BaseResource implements CommercePlanResource {
this.publiclyVisible = data.publicly_visible;
this.slug = data.slug;
this.avatarUrl = data.avatar_url;
this.freeTrialDays = this.withDefault(data.free_trial_days, null);
this.freeTrialEnabled = this.withDefault(data.free_trial_enabled, false);
this.features = (data.features || []).map(feature => new CommerceFeature(feature));

return this;
Expand Down
5 changes: 5 additions & 0 deletions packages/clerk-js/src/core/resources/CommerceSubscription.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export class CommerceSubscription extends BaseResource implements CommerceSubscr
date: Date;
} | null = null;
subscriptionItems!: CommerceSubscriptionItemResource[];
eligibleForFreeTrial?: boolean;

constructor(data: CommerceSubscriptionJSON) {
super();
Expand All @@ -51,6 +52,7 @@ export class CommerceSubscription extends BaseResource implements CommerceSubscr
}
: null;
this.subscriptionItems = (data.subscription_items || []).map(item => new CommerceSubscriptionItem(item));
this.eligibleForFreeTrial = data.eligible_for_free_trial;
return this;
}
}
Expand All @@ -71,6 +73,7 @@ export class CommerceSubscriptionItem extends BaseResource implements CommerceSu
credit?: {
amount: CommerceMoney;
};
isFreeTrial!: boolean;

constructor(data: CommerceSubscriptionItemJSON) {
super();
Expand All @@ -97,6 +100,8 @@ export class CommerceSubscriptionItem extends BaseResource implements CommerceSu

this.amount = data.amount ? commerceMoneyFromJSON(data.amount) : undefined;
this.credit = data.credit && data.credit.amount ? { amount: commerceMoneyFromJSON(data.credit.amount) } : undefined;

this.isFreeTrial = this.withDefault(data.is_free_trial, false);
return this;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,9 @@ function Card(props: CardProps) {
} else if (planPeriod !== subscription.planPeriod && plan.annualMonthlyAmount > 0) {
shouldShowFooter = true;
shouldShowFooterNotice = false;
} else if (plan.freeTrialEnabled && subscription.isFreeTrial) {
shouldShowFooter = true;
shouldShowFooterNotice = true;
} else {
shouldShowFooter = false;
shouldShowFooterNotice = false;
Expand Down Expand Up @@ -232,9 +235,13 @@ function Card(props: CardProps) {
<Text
elementDescriptor={descriptors.pricingTableCardFooterNotice}
variant={isCompact ? 'buttonSmall' : 'buttonLarge'}
localizationKey={localizationKeys('badge__startsAt', {
date: subscription?.periodStart,
})}
localizationKey={
plan.freeTrialEnabled && subscription.isFreeTrial && subscription.periodEnd
? localizationKeys('badge__trialEndsAt', {
date: subscription.periodEnd,
})
: localizationKeys('badge__startsAt', { date: subscription?.periodStart })
}
colorScheme='secondary'
sx={t => ({
paddingBlock: t.space.$1x5,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
import { render, waitFor } from '../../../../testUtils';
import { bindCreateFixtures } from '../../../utils/test/createFixtures';
import { PricingTable } from '..';

const { createFixtures } = bindCreateFixtures('PricingTable');

describe('PricingTable - trial info', () => {
const trialPlan = {
id: 'plan_trial',
name: 'Pro',
amount: 2000,
amountFormatted: '20.00',
annualAmount: 20000,
annualAmountFormatted: '200.00',
annualMonthlyAmount: 1667,
annualMonthlyAmountFormatted: '16.67',
currencySymbol: '$',
description: 'Pro plan with trial',
hasBaseFee: true,
isRecurring: true,
currency: 'USD',
isDefault: false,
forPayerType: 'user',
publiclyVisible: true,
slug: 'pro',
avatarUrl: '',
features: [] as any[],
freeTrialEnabled: true,
freeTrialDays: 14,
__internal_toSnapshot: jest.fn(),
pathRoot: '',
reload: jest.fn(),
} as const;

it('shows footer notice with trial end date when active subscription is in free trial', async () => {
const { wrapper, fixtures, props } = await createFixtures(f => {
f.withUser({ email_addresses: ['[email protected]'] });
});

// Provide empty props to the PricingTable context
props.setProps({});

fixtures.clerk.billing.getPlans.mockResolvedValue({ data: [trialPlan as any], total_count: 1 });
fixtures.clerk.billing.getSubscription.mockResolvedValue({
id: 'sub_1',
status: 'active',
activeAt: new Date('2021-01-01'),
createdAt: new Date('2021-01-01'),
nextPayment: null,
pastDueAt: null,
updatedAt: null,
subscriptionItems: [
{
id: 'si_1',
plan: trialPlan,
createdAt: new Date('2021-01-01'),
paymentSourceId: 'src_1',
pastDueAt: null,
canceledAt: null,
periodStart: new Date('2021-01-01'),
periodEnd: new Date('2021-01-15'),
planPeriod: 'month' as const,
status: 'active' as const,
isFreeTrial: true,
cancel: jest.fn(),
pathRoot: '',
reload: jest.fn(),
},
],
pathRoot: '',
reload: jest.fn(),
});

const { findByRole, getByText, userEvent } = render(<PricingTable />, { wrapper });

// Wait for the plan to appear
await findByRole('heading', { name: 'Pro' });

// Default period is annual in mounted mode; switch to monthly to match the subscription
const periodSwitch = await findByRole('switch', { name: /billed annually/i });
await userEvent.click(periodSwitch);

await waitFor(() => {
// Trial footer notice uses badge__trialEndsAt localization (short date format)
expect(getByText('Trial ends Jan 15, 2021')).toBeVisible();
});
});

it('shows CTA "Start N-day free trial" when eligible and plan has trial', async () => {
const { wrapper, fixtures, props } = await createFixtures(f => {
f.withUser({ email_addresses: ['[email protected]'] });
});

// Provide empty props to the PricingTable context
props.setProps({});

fixtures.clerk.billing.getPlans.mockResolvedValue({ data: [trialPlan as any], total_count: 1 });
fixtures.clerk.billing.getSubscription.mockResolvedValue({
id: 'sub_top',
status: 'active',
activeAt: new Date('2021-01-01'),
createdAt: new Date('2021-01-01'),
nextPayment: null,
pastDueAt: null,
updatedAt: null,
eligibleForFreeTrial: true,
// No subscription items for the trial plan yet
subscriptionItems: [],
pathRoot: '',
reload: jest.fn(),
});

const { getByRole, getByText } = render(<PricingTable />, { wrapper });

await waitFor(() => {
expect(getByRole('heading', { name: 'Pro' })).toBeVisible();
// Button text from Plans.buttonPropsForPlan via freeTrialOr
expect(getByText('Start 14-day free trial')).toBeVisible();
});
});

it('shows CTA "Start N-day free trial" when user is signed out and plan has trial', async () => {
const { wrapper, fixtures, props } = await createFixtures();

// Provide empty props to the PricingTable context
props.setProps({});

fixtures.clerk.billing.getPlans.mockResolvedValue({ data: [trialPlan as any], total_count: 1 });
// When signed out, getSubscription should throw or return empty response
fixtures.clerk.billing.getSubscription.mockRejectedValue(new Error('Unauthenticated'));

const { getByRole, getByText } = render(<PricingTable />, { wrapper });

await waitFor(() => {
expect(getByRole('heading', { name: 'Pro' })).toBeVisible();
// Signed out users should see free trial CTA when plan has trial enabled
expect(getByText('Start 14-day free trial')).toBeVisible();
});
});

it('shows CTA "Subscribe" when user is signed out and plan has no trial', async () => {
const { wrapper, fixtures, props } = await createFixtures();

const nonTrialPlan = {
...trialPlan,
id: 'plan_no_trial',
name: 'Basic',
freeTrialEnabled: false,
freeTrialDays: 0,
};

// Provide empty props to the PricingTable context
props.setProps({});

fixtures.clerk.billing.getPlans.mockResolvedValue({ data: [nonTrialPlan as any], total_count: 1 });
// When signed out, getSubscription should throw or return empty response
fixtures.clerk.billing.getSubscription.mockRejectedValue(new Error('Unauthenticated'));

const { getByRole, getByText } = render(<PricingTable />, { wrapper });

await waitFor(() => {
expect(getByRole('heading', { name: 'Basic' })).toBeVisible();
// Signed out users should see regular "Subscribe" for non-trial plans
expect(getByText('Subscribe')).toBeVisible();
});
});
});
23 changes: 19 additions & 4 deletions packages/clerk-js/src/ui/contexts/components/Plans.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ export const usePlansContext = () => {
return false;
}, [clerk, subscriberType]);

const { subscriptionItems, revalidate: revalidateSubscriptions } = useSubscription();
const { subscriptionItems, revalidate: revalidateSubscriptions, data: topLevelSubscription } = useSubscription();

// Invalidates cache but does not fetch immediately
const { data: plans, revalidate: revalidatePlans } = usePlans({ mode: 'cache' });
Expand Down Expand Up @@ -187,6 +187,7 @@ export const usePlansContext = () => {
const buttonPropsForPlan = useCallback(
({
plan,
// TODO(@COMMERCE): This needs to be removed.
subscription: sub,
isCompact = false,
selectedPlanPeriod = 'annual',
Expand All @@ -211,6 +212,19 @@ export const usePlansContext = () => {

const isEligibleForSwitchToAnnual = (plan?.annualMonthlyAmount ?? 0) > 0;

const freeTrialOr = (localizationKey: LocalizationKey): LocalizationKey => {
if (plan?.freeTrialEnabled) {
// Show trial CTA if user is signed out OR if signed in and eligible for free trial
const isSignedOut = !session;
const isEligibleForTrial = topLevelSubscription?.eligibleForFreeTrial;

if (isSignedOut || isEligibleForTrial) {
return localizationKeys('commerce.startFreeTrial', { days: plan.freeTrialDays ?? 0 });
}
}
return localizationKey;
};

const getLocalizationKey = () => {
// Handle subscription cases
if (subscription) {
Expand Down Expand Up @@ -246,20 +260,21 @@ export const usePlansContext = () => {
// Handle non-subscription cases
const hasNonDefaultSubscriptions =
subscriptionItems.filter(subscription => !subscription.plan.isDefault).length > 0;

return hasNonDefaultSubscriptions
? localizationKeys('commerce.switchPlan')
: localizationKeys('commerce.subscribe');
: freeTrialOr(localizationKeys('commerce.subscribe'));
};

return {
localizationKey: getLocalizationKey(),
localizationKey: freeTrialOr(getLocalizationKey()),
variant: isCompact ? 'bordered' : 'solid',
colorScheme: isCompact ? 'secondary' : 'primary',
isDisabled: !canManageBilling,
disabled: !canManageBilling,
};
},
[activeOrUpcomingSubscriptionWithPlanPeriod, canManageBilling, subscriptionItems],
[activeOrUpcomingSubscriptionWithPlanPeriod, canManageBilling, subscriptionItems, topLevelSubscription],
);

const captionForSubscription = useCallback((subscription: CommerceSubscriptionItemResource) => {
Expand Down
2 changes: 2 additions & 0 deletions packages/localizations/src/en-US.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ export const enUS: LocalizationResource = {
badge__default: 'Default',
badge__endsAt: "Ends {{ date | shortDate('en-US') }}",
badge__expired: 'Expired',
badge__trialEndsAt: "Trial ends {{ date | shortDate('en-US') }}",
badge__otherImpersonatorDevice: 'Other impersonator device',
badge__pastDueAt: "Past due {{ date | shortDate('en-US') }}",
badge__pastDuePlan: 'Past due',
Expand Down Expand Up @@ -160,6 +161,7 @@ export const enUS: LocalizationResource = {
},
subtotal: 'Subtotal',
switchPlan: 'Switch to this plan',
startFreeTrial: 'Start {{days}}-day free trial',
switchToAnnual: 'Switch to annual',
switchToAnnualWithAnnualPrice: 'Switch to annual {{currency}}{{price}} / year',
switchToMonthly: 'Switch to monthly',
Expand Down
37 changes: 37 additions & 0 deletions packages/types/src/commerce.ts
Original file line number Diff line number Diff line change
Expand Up @@ -437,6 +437,24 @@ export interface CommercePlanResource extends ClerkResource {
* ```
*/
features: CommerceFeatureResource[];
/**
* @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
* <ClerkProvider clerkJsVersion="x.x.x" />
* ```
*/
freeTrialDays: number | 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.
* @example
* ```tsx
* <ClerkProvider clerkJsVersion="x.x.x" />
* ```
*/
freeTrialEnabled: boolean;
__internal_toSnapshot: () => CommercePlanJSONSnapshot;
}

Expand Down Expand Up @@ -1094,6 +1112,15 @@ export interface CommerceSubscriptionItemResource extends ClerkResource {
* ```
*/
cancel: (params: CancelSubscriptionParams) => Promise<DeletedObjectResource>;
/**
* @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
* <ClerkProvider clerkJsVersion="x.x.x" />
* ```
*/
isFreeTrial: boolean;
}

/**
Expand Down Expand Up @@ -1203,6 +1230,16 @@ export interface CommerceSubscriptionResource extends ClerkResource {
* ```
*/
updatedAt: 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.
* @example
* ```tsx
* <ClerkProvider clerkJsVersion="x.x.x" />
* ```
*/
eligibleForFreeTrial?: boolean;
}

/**
Expand Down
Loading
Loading