diff --git a/.changeset/all-readers-dream.md b/.changeset/all-readers-dream.md new file mode 100644 index 00000000000..4e4355200fd --- /dev/null +++ b/.changeset/all-readers-dream.md @@ -0,0 +1,9 @@ +--- +'@clerk/backend': minor +'@clerk/localizations': minor +'@clerk/clerk-js': minor +'@clerk/shared': minor +'@clerk/ui': minor +--- + +Add support for annual-only Billing plans. diff --git a/packages/backend/src/api/resources/CommercePlan.ts b/packages/backend/src/api/resources/CommercePlan.ts index 05083d218a9..b1cca570c1f 100644 --- a/packages/backend/src/api/resources/CommercePlan.ts +++ b/packages/backend/src/api/resources/CommercePlan.ts @@ -45,7 +45,7 @@ export class BillingPlan { /** * The monthly fee of the Plan. */ - readonly fee: BillingMoneyAmount, + readonly fee: BillingMoneyAmount | null, /** * The annual fee of the Plan. */ diff --git a/packages/backend/src/api/resources/JSON.ts b/packages/backend/src/api/resources/JSON.ts index 2da34e59185..289c84fe7c6 100644 --- a/packages/backend/src/api/resources/JSON.ts +++ b/packages/backend/src/api/resources/JSON.ts @@ -854,7 +854,7 @@ export interface BillingPlanJSON extends ClerkResourceJSON { is_recurring: boolean; has_base_fee: boolean; publicly_visible: boolean; - fee: BillingMoneyAmountJSON; + fee: BillingMoneyAmountJSON | null; annual_fee: BillingMoneyAmountJSON | null; annual_monthly_fee: BillingMoneyAmountJSON | null; for_payer_type: 'org' | 'user'; diff --git a/packages/clerk-js/sandbox/scenarios/annual-only-plans.ts b/packages/clerk-js/sandbox/scenarios/annual-only-plans.ts new file mode 100644 index 00000000000..7e250ecc33a --- /dev/null +++ b/packages/clerk-js/sandbox/scenarios/annual-only-plans.ts @@ -0,0 +1,157 @@ +import { + clerkHandlers, + http, + HttpResponse, + EnvironmentService, + SessionService, + setClerkState, + type MockScenario, + UserService, +} from '@clerk/msw'; +import type { BillingPlanJSON } from '@clerk/shared/types'; + +export function AnnualOnlyPlans(): MockScenario { + const user = UserService.create(); + const session = SessionService.create(user); + const money = (amount: number) => ({ + amount, + amount_formatted: (amount / 100).toFixed(2), + currency: 'USD', + currency_symbol: '$', + }); + const mockFeatures = [ + { + object: 'feature' as const, + id: 'feature_custom_domains', + name: 'Custom domains', + description: 'Connect and manage branded domains.', + slug: 'custom-domains', + avatar_url: null, + }, + { + object: 'feature' as const, + id: 'feature_saml_sso', + name: 'SAML SSO', + description: 'Single sign-on with enterprise identity providers.', + slug: 'saml-sso', + avatar_url: null, + }, + { + object: 'feature' as const, + id: 'feature_audit_logs', + name: 'Audit logs', + description: 'Track account activity and security events.', + slug: 'audit-logs', + avatar_url: null, + }, + { + object: 'feature' as const, + id: 'feature_priority_support', + name: 'Priority support', + description: 'Faster response times from the support team.', + slug: 'priority-support', + avatar_url: null, + }, + { + object: 'feature' as const, + id: 'feature_rate_limit_boost', + name: 'Rate limit boost', + description: 'Higher API request thresholds for production traffic.', + slug: 'rate-limit-boost', + avatar_url: null, + }, + ]; + + setClerkState({ + environment: EnvironmentService.MULTI_SESSION, + session, + user, + }); + + const subscriptionHandler = http.get('https://*.clerk.accounts.dev/v1/me/billing/subscription', () => { + return HttpResponse.json({ + response: { + data: {}, + }, + }); + }); + + const paymentMethodsHandler = http.get('https://*.clerk.accounts.dev/v1/me/billing/payment_methods', () => { + return HttpResponse.json({ + response: { + data: {}, + }, + }); + }); + + const plansHandler = http.get('https://*.clerk.accounts.dev/v1/billing/plans', () => { + return HttpResponse.json({ + data: [ + { + object: 'commerce_plan', + id: 'plan_a_sbb', + name: 'Monthly-only', + fee: money(5000), + annual_fee: null, + annual_monthly_fee: null, + description: null, + is_default: false, + is_recurring: true, + has_base_fee: true, + for_payer_type: 'user', + publicly_visible: true, + slug: 'plan-a-sbb', + avatar_url: null, + features: mockFeatures, + free_trial_enabled: false, + free_trial_days: null, + }, + { + object: 'commerce_plan', + id: 'plan_b_sbb', + name: 'Monthly & Annual', + fee: money(5000), + annual_fee: money(50000), + annual_monthly_fee: money(4167), + description: null, + is_default: false, + is_recurring: true, + has_base_fee: true, + for_payer_type: 'user', + publicly_visible: true, + slug: 'plan-b-sbb', + avatar_url: null, + features: mockFeatures, + free_trial_enabled: false, + free_trial_days: null, + }, + { + object: 'commerce_plan', + id: 'plan_c_sbb', + name: 'Annual-only', + fee: null, + annual_fee: money(50000), + annual_monthly_fee: money(4167), + description: null, + is_default: false, + is_recurring: true, + has_base_fee: false, + for_payer_type: 'user', + publicly_visible: true, + slug: 'plan-c-sbb', + avatar_url: null, + features: mockFeatures, + free_trial_enabled: false, + free_trial_days: null, + }, + ] as BillingPlanJSON[], + }); + }); + + return { + description: 'PricingTable with annual-only billing plans', + handlers: [plansHandler, subscriptionHandler, paymentMethodsHandler, ...clerkHandlers], + initialState: { session, user }, + name: 'annual-only-plans', + }; +} diff --git a/packages/clerk-js/sandbox/scenarios/index.ts b/packages/clerk-js/sandbox/scenarios/index.ts index 73ddfca0ce6..8a974a68d49 100644 --- a/packages/clerk-js/sandbox/scenarios/index.ts +++ b/packages/clerk-js/sandbox/scenarios/index.ts @@ -1,2 +1,3 @@ export { UserButtonSignedIn } from './user-button-signed-in'; export { CheckoutAccountCredit } from './checkout-account-credit'; +export { AnnualOnlyPlans } from './annual-only-plans'; diff --git a/packages/clerk-js/src/core/resources/BillingPlan.ts b/packages/clerk-js/src/core/resources/BillingPlan.ts index 6afbdbfe3b9..f9159db563d 100644 --- a/packages/clerk-js/src/core/resources/BillingPlan.ts +++ b/packages/clerk-js/src/core/resources/BillingPlan.ts @@ -12,7 +12,7 @@ import { BaseResource, Feature } from './internal'; export class BillingPlan extends BaseResource implements BillingPlanResource { id!: string; name!: string; - fee!: BillingMoneyAmount; + fee: BillingMoneyAmount | null = null; annualFee: BillingMoneyAmount | null = null; annualMonthlyFee: BillingMoneyAmount | null = null; description: string | null = null; @@ -39,7 +39,7 @@ export class BillingPlan extends BaseResource implements BillingPlanResource { this.id = data.id; this.name = data.name; - this.fee = billingMoneyAmountFromJSON(data.fee); + this.fee = data.fee ? billingMoneyAmountFromJSON(data.fee) : null; this.annualFee = data.annual_fee ? billingMoneyAmountFromJSON(data.annual_fee) : null; this.annualMonthlyFee = data.annual_monthly_fee ? billingMoneyAmountFromJSON(data.annual_monthly_fee) : null; this.description = data.description; diff --git a/packages/localizations/src/en-US.ts b/packages/localizations/src/en-US.ts index 70fe35a6162..701fd4b9284 100644 --- a/packages/localizations/src/en-US.ts +++ b/packages/localizations/src/en-US.ts @@ -69,6 +69,7 @@ export const enUS: LocalizationResource = { availableFeatures: 'Available features', billedAnnually: 'Billed annually', billedMonthlyOnly: 'Only billed monthly', + billedAnnuallyOnly: 'Only billed annually', cancelFreeTrial: 'Cancel free trial', cancelFreeTrialAccessUntil: "Your trial will stay active until {{ date | longDate('en-US') }}. After that, you'll lose access to trial features. You won't be charged.", diff --git a/packages/msw/EnvironmentService.ts b/packages/msw/EnvironmentService.ts index 108b752f76c..8c2fac19d76 100644 --- a/packages/msw/EnvironmentService.ts +++ b/packages/msw/EnvironmentService.ts @@ -32,13 +32,13 @@ const singleSessionEnvironment: EnvironmentPreset = { commerce_settings: { billing: { organization: { - enabled: false, - has_paid_plans: false, + enabled: true, + has_paid_plans: true, }, stripe_publishable_key: '', user: { - enabled: false, - has_paid_plans: false, + enabled: true, + has_paid_plans: true, }, }, id: 'commerce_settings_1', diff --git a/packages/shared/src/types/billing.ts b/packages/shared/src/types/billing.ts index 97348f9a81b..89e1ea5092d 100644 --- a/packages/shared/src/types/billing.ts +++ b/packages/shared/src/types/billing.ts @@ -159,7 +159,7 @@ export interface BillingPlanResource extends ClerkResource { /** * The monthly price of the Plan. */ - fee: BillingMoneyAmount; + fee: BillingMoneyAmount | null; /** * The annual price of the Plan or `null` if the Plan is not annual. */ diff --git a/packages/shared/src/types/json.ts b/packages/shared/src/types/json.ts index 2d739b018da..c9bf7ddb2b6 100644 --- a/packages/shared/src/types/json.ts +++ b/packages/shared/src/types/json.ts @@ -605,7 +605,7 @@ export interface BillingPlanJSON extends ClerkResourceJSON { object: 'commerce_plan'; id: string; name: string; - fee: BillingMoneyAmountJSON; + fee: BillingMoneyAmountJSON | null; annual_fee: BillingMoneyAmountJSON | null; annual_monthly_fee: BillingMoneyAmountJSON | null; description: string | null; diff --git a/packages/shared/src/types/localization.ts b/packages/shared/src/types/localization.ts index e9d6db10850..63ad4de0f21 100644 --- a/packages/shared/src/types/localization.ts +++ b/packages/shared/src/types/localization.ts @@ -193,6 +193,7 @@ export type __internal_LocalizationResource = { switchToAnnualWithAnnualPrice: LocalizationValue<'price' | 'currency'>; billedAnnually: LocalizationValue; billedMonthlyOnly: LocalizationValue; + billedAnnuallyOnly: LocalizationValue; cancelFreeTrial: LocalizationValue<'plan'>; cancelFreeTrialTitle: LocalizationValue<'plan'>; cancelFreeTrialAccessUntil: LocalizationValue<'plan' | 'date'>; diff --git a/packages/ui/src/components/Plans/PlanDetails.tsx b/packages/ui/src/components/Plans/PlanDetails.tsx index ba5bde2d969..138862245a8 100644 --- a/packages/ui/src/components/Plans/PlanDetails.tsx +++ b/packages/ui/src/components/Plans/PlanDetails.tsx @@ -226,8 +226,11 @@ const Header = React.forwardRef((props, ref) => { }, [plan, planPeriod]); const feeFormatted = React.useMemo(() => { - return normalizeFormatted(fee.amountFormatted); - }, [fee.amountFormatted]); + if (!fee) { + return ''; + } + return `${fee.currencySymbol}${normalizeFormatted(fee.amountFormatted)}`; + }, [fee]); return ( ((props, ref) => { variant='h1' colorScheme='body' > - {fee.currencySymbol} {feeFormatted} ((props, ref) => { - {plan.annualMonthlyFee ? ( - ({ - display: 'flex', - marginTop: t.space.$3, - })} - > - setPlanPeriod(checked ? 'annual' : 'month')} - label={localizationKeys('billing.billedAnnually')} - /> - - ) : ( - ({ - justifySelf: 'flex-start', - alignSelf: 'center', - marginTop: t.space.$3, - })} - /> - )} + ); }); + +const PeriodToggle = ({ + plan, + planPeriod, + setPlanPeriod, +}: { + plan: BillingPlanResource; + planPeriod: BillingSubscriptionPlanPeriod; + setPlanPeriod: (val: BillingSubscriptionPlanPeriod) => void; +}) => { + if (plan.fee && plan.annualMonthlyFee) { + return ( + ({ + display: 'flex', + marginTop: t.space.$3, + })} + > + setPlanPeriod(checked ? 'annual' : 'month')} + label={localizationKeys('billing.billedAnnually')} + /> + + ); + } + + if (plan.annualMonthlyFee) { + return ( + ({ + justifySelf: 'flex-start', + alignSelf: 'center', + marginTop: t.space.$3, + })} + /> + ); + } + + return ( + ({ + justifySelf: 'flex-start', + alignSelf: 'center', + marginTop: t.space.$3, + })} + /> + ); +}; diff --git a/packages/ui/src/components/PricingTable/PricingTableDefault.tsx b/packages/ui/src/components/PricingTable/PricingTableDefault.tsx index 1e512ad33a9..fd6e4ba74cc 100644 --- a/packages/ui/src/components/PricingTable/PricingTableDefault.tsx +++ b/packages/ui/src/components/PricingTable/PricingTableDefault.tsx @@ -281,24 +281,26 @@ interface CardHeaderProps { const CardHeader = React.forwardRef((props, ref) => { const { plan, isCompact, planPeriod, setPlanPeriod, badge } = props; - const { name, annualMonthlyFee } = plan; - - const planSupportsAnnual = Boolean(annualMonthlyFee); + const { name } = plan; const fee = React.useMemo(() => { - if (!planSupportsAnnual) { + if (!plan.annualMonthlyFee) { return plan.fee; } - return planPeriod === 'annual' - ? // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - plan.annualMonthlyFee! - : plan.fee; - }, [planSupportsAnnual, planPeriod, plan.fee, plan.annualMonthlyFee]); + if (!plan.fee) { + return plan.annualFee; + } + + return planPeriod === 'annual' ? plan.annualMonthlyFee : plan.fee; + }, [plan, planPeriod]); const feeFormatted = React.useMemo(() => { - return normalizeFormatted(fee.amountFormatted); - }, [fee.amountFormatted]); + if (!fee) { + return ''; + } + return `${fee.currencySymbol}${normalizeFormatted(fee.amountFormatted)}`; + }, [fee]); return ( ((props, ref variant={isCompact ? 'h2' : 'h1'} colorScheme='body' > - {fee.currencySymbol} {feeFormatted} {!plan.isDefault ? ( @@ -376,43 +377,81 @@ const CardHeader = React.forwardRef((props, ref marginInlineEnd: t.space.$0x25, }, })} - localizationKey={localizationKeys('billing.month')} + localizationKey={plan.fee ? localizationKeys('billing.month') : localizationKeys('billing.year')} /> ) : null} - {planSupportsAnnual && setPlanPeriod ? ( - ({ - marginTop: t.space.$1, - })} - > - setPlanPeriod(checked ? 'annual' : 'month')} - label={localizationKeys('billing.billedAnnually')} - /> - - ) : ( - ({ - justifySelf: 'flex-start', - alignSelf: 'center', - marginTop: t.space.$1, - })} - /> - )} + ); }); +const PeriodToggle = ({ + plan, + planPeriod, + setPlanPeriod, +}: { + plan: BillingPlanResource; + planPeriod: BillingSubscriptionPlanPeriod; + setPlanPeriod: (val: BillingSubscriptionPlanPeriod) => void; +}) => { + if (plan.fee && plan.annualMonthlyFee) { + return ( + ({ + marginTop: t.space.$1, + })} + > + setPlanPeriod(checked ? 'annual' : 'month')} + label={localizationKeys('billing.billedAnnually')} + /> + + ); + } + + if (plan.annualMonthlyFee) { + return ( + ({ + justifySelf: 'flex-start', + alignSelf: 'center', + marginTop: t.space.$1, + })} + /> + ); + } + + return ( + ({ + justifySelf: 'flex-start', + alignSelf: 'center', + marginTop: t.space.$1, + })} + /> + ); +}; + /* ------------------------------------------------------------------------------------------------- * CardFeaturesList * -----------------------------------------------------------------------------------------------*/ diff --git a/packages/ui/src/contexts/components/Plans.tsx b/packages/ui/src/contexts/components/Plans.tsx index edd7b9891ee..352533faf5f 100644 --- a/packages/ui/src/contexts/components/Plans.tsx +++ b/packages/ui/src/contexts/components/Plans.tsx @@ -328,7 +328,7 @@ export const usePlansContext = () => { clerk.__internal_openCheckout({ planId: plan.id, // if the plan doesn't support annual, use monthly - planPeriod: planPeriod === 'annual' && !plan.annualMonthlyFee ? 'month' : planPeriod, + planPeriod: determinePlanPeriod(plan, planPeriod), for: subscriberType, onSubscriptionComplete: () => { revalidateAll(); @@ -364,3 +364,19 @@ export const usePlansContext = () => { revalidateAll, }; }; + +function determinePlanPeriod(plan: BillingPlanResource, period: BillingSubscriptionPlanPeriod) { + if ((period === 'month' && plan.fee) || (period === 'annual' && plan.annualMonthlyFee)) { + return period; + } + + if (period === 'month' && !plan.fee) { + return 'annual'; + } + + if (period === 'annual' && !plan.annualMonthlyFee) { + return 'month'; + } + + return period; +}