diff --git a/.changeset/bumpy-glasses-eat.md b/.changeset/bumpy-glasses-eat.md new file mode 100644 index 00000000000..617b643813d --- /dev/null +++ b/.changeset/bumpy-glasses-eat.md @@ -0,0 +1,6 @@ +--- +'@clerk/clerk-js': minor +'@clerk/types': minor +--- + +Remove the requirement to add a payment method when subscribing to free trials, depending on a flag \ No newline at end of file diff --git a/packages/clerk-js/bundlewatch.config.json b/packages/clerk-js/bundlewatch.config.json index 86aed3c552c..cd4f9daca84 100644 --- a/packages/clerk-js/bundlewatch.config.json +++ b/packages/clerk-js/bundlewatch.config.json @@ -24,7 +24,7 @@ { "path": "./dist/waitlist*.js", "maxSize": "1.5KB" }, { "path": "./dist/keylessPrompt*.js", "maxSize": "6.5KB" }, { "path": "./dist/pricingTable*.js", "maxSize": "4.02KB" }, - { "path": "./dist/checkout*.js", "maxSize": "8.77KB" }, + { "path": "./dist/checkout*.js", "maxSize": "10KB" }, { "path": "./dist/up-billing-page*.js", "maxSize": "3.0KB" }, { "path": "./dist/op-billing-page*.js", "maxSize": "3.0KB" }, { "path": "./dist/up-plans-page*.js", "maxSize": "1.0KB" }, diff --git a/packages/clerk-js/src/core/resources/CommerceSettings.ts b/packages/clerk-js/src/core/resources/CommerceSettings.ts index 4a543ec9ea3..5d25df65046 100644 --- a/packages/clerk-js/src/core/resources/CommerceSettings.ts +++ b/packages/clerk-js/src/core/resources/CommerceSettings.ts @@ -8,6 +8,7 @@ import { BaseResource } from './internal'; export class CommerceSettings extends BaseResource implements CommerceSettingsResource { billing: CommerceSettingsResource['billing'] = { stripePublishableKey: '', + freeTrialRequiresPaymentMethod: true, organization: { enabled: false, hasPaidPlans: false, @@ -29,6 +30,7 @@ export class CommerceSettings extends BaseResource implements CommerceSettingsRe } this.billing.stripePublishableKey = data.billing.stripe_publishable_key || ''; + this.billing.freeTrialRequiresPaymentMethod = data.billing.free_trial_requires_payment_method ?? true; this.billing.organization.enabled = data.billing.organization.enabled || false; this.billing.organization.hasPaidPlans = data.billing.organization.has_paid_plans || false; this.billing.user.enabled = data.billing.user.enabled || false; @@ -41,6 +43,7 @@ export class CommerceSettings extends BaseResource implements CommerceSettingsRe return { billing: { stripe_publishable_key: this.billing.stripePublishableKey, + free_trial_requires_payment_method: this.billing.freeTrialRequiresPaymentMethod, organization: { enabled: this.billing.organization.enabled, has_paid_plans: this.billing.organization.hasPaidPlans, diff --git a/packages/clerk-js/src/test/fixture-helpers.ts b/packages/clerk-js/src/test/fixture-helpers.ts index 143e7e6a7c3..816996d289b 100644 --- a/packages/clerk-js/src/test/fixture-helpers.ts +++ b/packages/clerk-js/src/test/fixture-helpers.ts @@ -362,11 +362,24 @@ const createOrganizationSettingsFixtureHelpers = (environment: EnvironmentJSON) const createBillingSettingsFixtureHelpers = (environment: EnvironmentJSON) => { const os = environment.commerce_settings.billing; - const withBilling = () => { - os.user.enabled = true; - os.user.has_paid_plans = true; - os.organization.enabled = true; - os.organization.has_paid_plans = true; + const withBilling = ({ + userEnabled = true, + userHasPaidPlans = true, + organizationEnabled = true, + organizationHasPaidPlans = true, + freeTrialRequiresPaymentMethod = true, + }: { + userEnabled?: boolean; + userHasPaidPlans?: boolean; + organizationEnabled?: boolean; + organizationHasPaidPlans?: boolean; + freeTrialRequiresPaymentMethod?: boolean; + } = {}) => { + os.user.enabled = userEnabled; + os.user.has_paid_plans = userHasPaidPlans; + os.organization.enabled = organizationEnabled; + os.organization.has_paid_plans = organizationHasPaidPlans; + os.free_trial_requires_payment_method = freeTrialRequiresPaymentMethod; }; return { withBilling }; diff --git a/packages/clerk-js/src/ui/components/Checkout/CheckoutComplete.tsx b/packages/clerk-js/src/ui/components/Checkout/CheckoutComplete.tsx index 28eb380ac83..3cf2c97ff64 100644 --- a/packages/clerk-js/src/ui/components/Checkout/CheckoutComplete.tsx +++ b/packages/clerk-js/src/ui/components/Checkout/CheckoutComplete.tsx @@ -5,7 +5,7 @@ import { Drawer, useDrawerContext } from '@/ui/elements/Drawer'; import { LineItems } from '@/ui/elements/LineItems'; import { formatDate } from '@/ui/utils/formatDate'; -import { useCheckoutContext } from '../../contexts'; +import { useCheckoutContext, useEnvironment } from '../../contexts'; import { Box, Button, descriptors, Heading, localizationKeys, Span, Text, useAppearance } from '../../customizables'; import { transitionDurationValues, transitionTiming } from '../../foundations/transitions'; import { usePrefersReducedMotion } from '../../hooks'; @@ -162,6 +162,7 @@ export const CheckoutComplete = () => { const { newSubscriptionRedirectUrl } = useCheckoutContext(); const { checkout } = useCheckout(); const { totals, paymentMethod, planPeriodStart, freeTrialEndsAt } = checkout; + const environment = useEnvironment(); const [mousePosition, setMousePosition] = useState({ x: 256, y: 256 }); const prefersReducedMotion = usePrefersReducedMotion(); @@ -430,14 +431,16 @@ export const CheckoutComplete = () => { 0 || freeTrialEndsAt !== null + totals.totalDueNow.amount > 0 || + (freeTrialEndsAt !== null && environment.commerceSettings.billing.freeTrialRequiresPaymentMethod) ? localizationKeys('billing.checkout.lineItems.title__paymentMethod') : localizationKeys('billing.checkout.lineItems.title__subscriptionBegins') } /> 0 || freeTrialEndsAt !== null + totals.totalDueNow.amount > 0 || + (freeTrialEndsAt !== null && environment.commerceSettings.billing.freeTrialRequiresPaymentMethod) ? paymentMethod ? paymentMethod.paymentType !== 'card' ? `${capitalize(paymentMethod.paymentType)}` diff --git a/packages/clerk-js/src/ui/components/Checkout/CheckoutForm.tsx b/packages/clerk-js/src/ui/components/Checkout/CheckoutForm.tsx index acfaf86c7ae..4c240fb7637 100644 --- a/packages/clerk-js/src/ui/components/Checkout/CheckoutForm.tsx +++ b/packages/clerk-js/src/ui/components/Checkout/CheckoutForm.tsx @@ -12,9 +12,10 @@ import { Tooltip } from '@/ui/elements/Tooltip'; import { handleError } from '@/ui/utils/errorHandler'; import { DevOnly } from '../../common/DevOnly'; -import { useCheckoutContext, usePaymentMethods } from '../../contexts'; +import { useCheckoutContext, useEnvironment, usePaymentMethods } from '../../contexts'; import { Box, Button, Col, descriptors, Flex, Form, localizationKeys, Spinner, Text } from '../../customizables'; import { ChevronUpDown, InformationCircle } from '../../icons'; +import type { PropsOfComponent, ThemableCssProp } from '../../styledSystem'; import * as AddPaymentMethod from '../PaymentMethods/AddPaymentMethod'; import { PaymentMethodRow } from '../PaymentMethods/PaymentMethodRow'; import { SubscriptionBadge } from '../Subscriptions/badge'; @@ -138,7 +139,7 @@ export const CheckoutForm = withCardStateProvider(() => { }); const useCheckoutMutations = () => { - const { for: _for, onSubscriptionComplete } = useCheckoutContext(); + const { onSubscriptionComplete } = useCheckoutContext(); const { checkout } = useCheckout(); const { id, confirm } = checkout; const card = useCardState(); @@ -172,6 +173,11 @@ const useCheckoutMutations = () => { }); }; + const payWithoutPaymentMethod = (e: React.FormEvent) => { + e.preventDefault(); + return confirmCheckout({}); + }; + const addPaymentMethodAndPay = (ctx: { gateway: 'stripe'; paymentToken: string }) => confirmCheckout(ctx); const payWithTestCard = () => @@ -184,6 +190,7 @@ const useCheckoutMutations = () => { payWithExistingPaymentMethod, addPaymentMethodAndPay, payWithTestCard, + payWithoutPaymentMethod, }; }; @@ -214,12 +221,15 @@ const CheckoutFormElementsInternal = () => { const { checkout } = useCheckout(); const { id, totals, isImmediatePlanChange, freeTrialEndsAt } = checkout; const { data: paymentMethods } = usePaymentMethods(); + const environment = useEnvironment(); const [paymentMethodSource, setPaymentMethodSource] = useState(() => paymentMethods.length > 0 || __BUILD_DISABLE_RHC__ ? 'existing' : 'new', ); - const showPaymentMethods = isImmediatePlanChange && (totals.totalDueNow.amount > 0 || !!freeTrialEndsAt); + const isFreeTrial = Boolean(freeTrialEndsAt); + const showTabs = isImmediatePlanChange && (totals.totalDueNow.amount > 0 || isFreeTrial); + const needsPaymentMethod = !(isFreeTrial && !environment.commerceSettings.billing.freeTrialRequiresPaymentMethod); if (!id) { return null; @@ -233,7 +243,7 @@ const CheckoutFormElementsInternal = () => { > {__BUILD_DISABLE_RHC__ ? null : ( <> - {paymentMethods.length > 0 && showPaymentMethods && ( + {paymentMethods.length > 0 && showTabs && needsPaymentMethod && ( { )} - {paymentMethodSource === 'existing' && ( - - )} + {paymentMethodSource === 'existing' && + (needsPaymentMethod ? ( + + ) : ( + + ))} {__BUILD_DISABLE_RHC__ ? null : paymentMethodSource === 'new' && } @@ -344,6 +357,22 @@ const useSubmitLabel = () => { return localizationKeys('billing.subscribe'); }; +const FreeTrialButton = withCardStateProvider(() => { + const { for: _for } = useCheckoutContext(); + const { payWithoutPaymentMethod } = useCheckoutMutations(); + const card = useCardState(); + + return ( +
+ {card.error} + + + ); +}); + const AddPaymentMethodForCheckout = withCardStateProvider(() => { const { addPaymentMethodAndPay } = useCheckoutMutations(); const submitLabel = useSubmitLabel(); @@ -363,6 +392,32 @@ const AddPaymentMethodForCheckout = withCardStateProvider(() => { ); }); +const CheckoutSubmitButton = (props: PropsOfComponent) => { + const card = useCardState(); + const submitLabel = useSubmitLabel(); + + return ( +