Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
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/bumpy-glasses-eat.md
Original file line number Diff line number Diff line change
@@ -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
3 changes: 3 additions & 0 deletions packages/clerk-js/src/core/resources/CommerceSettings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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;
Expand All @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -430,14 +431,16 @@ export const CheckoutComplete = () => {
<LineItems.Group variant='secondary'>
<LineItems.Title
title={
totals.totalDueNow.amount > 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')
}
/>
<LineItems.Description
text={
totals.totalDueNow.amount > 0 || freeTrialEndsAt !== null
totals.totalDueNow.amount > 0 ||
(freeTrialEndsAt !== null && environment.commerceSettings.billing.freeTrialRequiresPaymentMethod)
? paymentMethod
? paymentMethod.paymentType !== 'card'
? `${capitalize(paymentMethod.paymentType)}`
Expand Down
76 changes: 71 additions & 5 deletions packages/clerk-js/src/ui/components/Checkout/CheckoutForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ 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 * as AddPaymentMethod from '../PaymentMethods/AddPaymentMethod';
Expand Down Expand Up @@ -214,12 +214,19 @@ const CheckoutFormElementsInternal = () => {
const { checkout } = useCheckout();
const { id, totals, isImmediatePlanChange, freeTrialEndsAt } = checkout;
const { data: paymentMethods } = usePaymentMethods();
const environment = useEnvironment();

const [paymentMethodSource, setPaymentMethodSource] = useState<PaymentMethodSource>(() =>
paymentMethods.length > 0 || __BUILD_DISABLE_RHC__ ? 'existing' : 'new',
);

const showPaymentMethods = isImmediatePlanChange && (totals.totalDueNow.amount > 0 || !!freeTrialEndsAt);
// Check if payment methods should be shown based on:
// 1. Immediate plan change (not a downgrade)
// 2. Either there's an amount due now OR it's a free trial that requires payment method
const showPaymentMethods =
isImmediatePlanChange &&
(totals.totalDueNow.amount > 0 ||
(!!freeTrialEndsAt && environment.commerceSettings.billing.freeTrialRequiresPaymentMethod));

if (!id) {
return null;
Expand Down Expand Up @@ -254,14 +261,18 @@ const CheckoutFormElementsInternal = () => {
</>
)}

{paymentMethodSource === 'existing' && (
{showPaymentMethods && paymentMethodSource === 'existing' && (
<ExistingPaymentMethodForm
paymentMethods={paymentMethods}
totalDueNow={totals.totalDueNow}
/>
)}

{__BUILD_DISABLE_RHC__ ? null : paymentMethodSource === 'new' && <AddPaymentMethodForCheckout />}
{__BUILD_DISABLE_RHC__
? null
: showPaymentMethods && paymentMethodSource === 'new' && <AddPaymentMethodForCheckout />}

{!showPaymentMethods && <FreeTrialButton />}
</Col>
);
};
Expand Down Expand Up @@ -344,6 +355,57 @@ const useSubmitLabel = () => {
return localizationKeys('billing.subscribe');
};

const FreeTrialButton = withCardStateProvider(() => {
const { for: _for, onSubscriptionComplete } = useCheckoutContext();
const submitLabel = useSubmitLabel();
const card = useCardState();
const { checkout } = useCheckout();

const handleFreeTrialStart = async () => {
card.setLoading();
card.setError(undefined);

try {
// For free trials without payment method requirement, we can confirm without payment details
const { data, error } = await checkout.confirm({});

if (error) {
handleError(error, [], card.setError);
} else if (data) {
onSubscriptionComplete?.();
}
} catch (error) {
handleError(error, [], card.setError);
} finally {
card.setIdle();
}
};

return (
<Form
sx={t => ({
display: 'flex',
flexDirection: 'column',
rowGap: t.space.$4,
})}
>
<Card.Alert>{card.error}</Card.Alert>
<Button
type='button'
colorScheme='primary'
size='sm'
textVariant={'buttonLarge'}
sx={{
width: '100%',
}}
isLoading={card.isLoading}
localizationKey={submitLabel}
onClick={handleFreeTrialStart}
/>
</Form>
);
});

const AddPaymentMethodForCheckout = withCardStateProvider(() => {
const { addPaymentMethodAndPay } = useCheckoutMutations();
const submitLabel = useSubmitLabel();
Expand Down Expand Up @@ -374,6 +436,7 @@ const ExistingPaymentMethodForm = withCardStateProvider(
const submitLabel = useSubmitLabel();
const { checkout } = useCheckout();
const { paymentMethod, isImmediatePlanChange, freeTrialEndsAt } = checkout;
const environment = useEnvironment();

const { payWithExistingPaymentMethod } = useCheckoutMutations();
const card = useCardState();
Expand All @@ -395,7 +458,10 @@ const ExistingPaymentMethodForm = withCardStateProvider(
});
}, [paymentMethods]);

const showPaymentMethods = isImmediatePlanChange && (totalDueNow.amount > 0 || !!freeTrialEndsAt);
const showPaymentMethods =
isImmediatePlanChange &&
(totalDueNow.amount > 0 ||
(!!freeTrialEndsAt && environment.commerceSettings.billing.freeTrialRequiresPaymentMethod));

return (
<Form
Expand Down
7 changes: 7 additions & 0 deletions packages/types/src/commerceSettings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type { CommerceSettingsJSONSnapshot } from './snapshots';
export interface CommerceSettingsJSON extends ClerkResourceJSON {
billing: {
stripe_publishable_key: string;
free_trial_requires_payment_method?: boolean;
organization: {
enabled: boolean;
has_paid_plans: boolean;
Expand All @@ -19,6 +20,12 @@ export interface CommerceSettingsJSON extends ClerkResourceJSON {
export interface CommerceSettingsResource extends ClerkResource {
billing: {
stripePublishableKey: string;
/**
* Whether payment methods are required when starting a free trial.
* When false, users can start free trials without providing payment methods.
* @default true
*/
freeTrialRequiresPaymentMethod: boolean;
organization: {
enabled: boolean;
hasPaidPlans: boolean;
Expand Down
Loading