Skip to content

Commit 5fb8034

Browse files
committed
feat(clerk-js,types): Make payment method optional for free trials
It's flag-based.
1 parent 2a815eb commit 5fb8034

File tree

6 files changed

+83
-18
lines changed

6 files changed

+83
-18
lines changed

.changeset/bumpy-glasses-eat.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'@clerk/clerk-js': minor
3+
'@clerk/types': minor
4+
---
5+
6+
Remove the requirement to add a payment method when subscribing to free trials, depending on a flag

packages/clerk-js/src/core/resources/CommerceSettings.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { BaseResource } from './internal';
88
export class CommerceSettings extends BaseResource implements CommerceSettingsResource {
99
billing: CommerceSettingsResource['billing'] = {
1010
stripePublishableKey: '',
11+
freeTrialRequiresPaymentMethod: true,
1112
organization: {
1213
enabled: false,
1314
hasPaidPlans: false,
@@ -29,6 +30,7 @@ export class CommerceSettings extends BaseResource implements CommerceSettingsRe
2930
}
3031

3132
this.billing.stripePublishableKey = data.billing.stripe_publishable_key || '';
33+
this.billing.freeTrialRequiresPaymentMethod = data.billing.free_trial_requires_payment_method ?? true;
3234
this.billing.organization.enabled = data.billing.organization.enabled || false;
3335
this.billing.organization.hasPaidPlans = data.billing.organization.has_paid_plans || false;
3436
this.billing.user.enabled = data.billing.user.enabled || false;
@@ -41,6 +43,7 @@ export class CommerceSettings extends BaseResource implements CommerceSettingsRe
4143
return {
4244
billing: {
4345
stripe_publishable_key: this.billing.stripePublishableKey,
46+
free_trial_requires_payment_method: this.billing.freeTrialRequiresPaymentMethod,
4447
organization: {
4548
enabled: this.billing.organization.enabled,
4649
has_paid_plans: this.billing.organization.hasPaidPlans,

packages/clerk-js/src/ui/components/Checkout/CheckoutForm.tsx

Lines changed: 36 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { __experimental_useCheckout as useCheckout } from '@clerk/shared/react';
2-
import type { BillingMoneyAmount, BillingPaymentMethodResource, ConfirmCheckoutParams } from '@clerk/types';
2+
import type { BillingPaymentMethodResource, ConfirmCheckoutParams } from '@clerk/types';
33
import { useMemo, useState } from 'react';
44

55
import { Card } from '@/ui/elements/Card';
@@ -13,6 +13,7 @@ import { handleError } from '@/ui/utils/errorHandler';
1313

1414
import { DevOnly } from '../../common/DevOnly';
1515
import { useCheckoutContext, usePaymentMethods } from '../../contexts';
16+
import { useShowPaymentMethods } from '../../hooks';
1617
import { Box, Button, Col, descriptors, Flex, Form, localizationKeys, Spinner, Text } from '../../customizables';
1718
import { ChevronUpDown, InformationCircle } from '../../icons';
1819
import * as AddPaymentMethod from '../PaymentMethods/AddPaymentMethod';
@@ -180,10 +181,13 @@ const useCheckoutMutations = () => {
180181
useTestCard: true,
181182
});
182183

184+
const subscribeWithoutPaymentMethod = () => confirmCheckout({});
185+
183186
return {
184187
payWithExistingPaymentMethod,
185188
addPaymentMethodAndPay,
186189
payWithTestCard,
190+
subscribeWithoutPaymentMethod,
187191
};
188192
};
189193

@@ -212,14 +216,14 @@ const CheckoutFormElements = () => {
212216

213217
const CheckoutFormElementsInternal = () => {
214218
const { checkout } = useCheckout();
215-
const { id, totals, isImmediatePlanChange, freeTrialEndsAt } = checkout;
219+
const { id, isImmediatePlanChange } = checkout;
216220
const { data: paymentMethods } = usePaymentMethods();
217221

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

222-
const showPaymentMethods = isImmediatePlanChange && (totals.totalDueNow.amount > 0 || !!freeTrialEndsAt);
226+
const showPaymentMethods = useShowPaymentMethods();
223227

224228
if (!id) {
225229
return null;
@@ -254,14 +258,16 @@ const CheckoutFormElementsInternal = () => {
254258
</>
255259
)}
256260

257-
{paymentMethodSource === 'existing' && (
258-
<ExistingPaymentMethodForm
259-
paymentMethods={paymentMethods}
260-
totalDueNow={totals.totalDueNow}
261-
/>
261+
{showPaymentMethods && paymentMethodSource === 'existing' && (
262+
<ExistingPaymentMethodForm paymentMethods={paymentMethods} />
262263
)}
263264

264-
{__BUILD_DISABLE_RHC__ ? null : paymentMethodSource === 'new' && <AddPaymentMethodForCheckout />}
265+
{__BUILD_DISABLE_RHC__
266+
? null
267+
: showPaymentMethods && paymentMethodSource === 'new' && <AddPaymentMethodForCheckout />}
268+
269+
{/* Show standalone subscribe button when payment methods are not needed */}
270+
{!showPaymentMethods && isImmediatePlanChange && <StandaloneSubscribeButton />}
265271
</Col>
266272
);
267273
};
@@ -364,16 +370,10 @@ const AddPaymentMethodForCheckout = withCardStateProvider(() => {
364370
});
365371

366372
const ExistingPaymentMethodForm = withCardStateProvider(
367-
({
368-
totalDueNow,
369-
paymentMethods,
370-
}: {
371-
totalDueNow: BillingMoneyAmount;
372-
paymentMethods: BillingPaymentMethodResource[];
373-
}) => {
373+
({ paymentMethods }: { paymentMethods: BillingPaymentMethodResource[] }) => {
374374
const submitLabel = useSubmitLabel();
375375
const { checkout } = useCheckout();
376-
const { paymentMethod, isImmediatePlanChange, freeTrialEndsAt } = checkout;
376+
const { paymentMethod } = checkout;
377377

378378
const { payWithExistingPaymentMethod } = useCheckoutMutations();
379379
const card = useCardState();
@@ -395,7 +395,7 @@ const ExistingPaymentMethodForm = withCardStateProvider(
395395
});
396396
}, [paymentMethods]);
397397

398-
const showPaymentMethods = isImmediatePlanChange && (totalDueNow.amount > 0 || !!freeTrialEndsAt);
398+
const showPaymentMethods = useShowPaymentMethods();
399399

400400
return (
401401
<Form
@@ -462,3 +462,21 @@ const ExistingPaymentMethodForm = withCardStateProvider(
462462
);
463463
},
464464
);
465+
466+
const StandaloneSubscribeButton = withCardStateProvider(() => {
467+
const { subscribeWithoutPaymentMethod } = useCheckoutMutations();
468+
const submitLabel = useSubmitLabel();
469+
const card = useCardState();
470+
471+
return (
472+
<Button
473+
elementDescriptor={descriptors.formButtonPrimary}
474+
onClick={subscribeWithoutPaymentMethod}
475+
sx={{
476+
width: '100%',
477+
}}
478+
isLoading={card.isLoading}
479+
localizationKey={submitLabel}
480+
/>
481+
);
482+
});

packages/clerk-js/src/ui/hooks/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,4 +16,5 @@ export * from './usePrefersReducedMotion';
1616
export * from './useSafeState';
1717
export * from './useScrollLock';
1818
export * from './useSearchInput';
19+
export * from './useShowPaymentMethods';
1920
export * from './useWindowEventListener';
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { __experimental_useCheckout as useCheckout } from '@clerk/shared/react';
2+
3+
import { useEnvironment } from '../contexts';
4+
5+
/**
6+
* Custom hook that determines whether payment methods should be shown in checkout flows.
7+
*
8+
* Payment methods are shown when:
9+
* 1. It's an immediate plan change (not a downgrade)
10+
* 2. Either there's an amount due now OR it's a free trial that requires payment method
11+
*
12+
* @returns boolean indicating whether payment methods should be displayed
13+
*/
14+
export const useShowPaymentMethods = (): boolean => {
15+
const { checkout } = useCheckout();
16+
const environment = useEnvironment();
17+
18+
const { isImmediatePlanChange, totals, freeTrialEndsAt } = checkout;
19+
20+
// Return false if checkout data is not loaded yet
21+
if (!isImmediatePlanChange || !totals) {
22+
return false;
23+
}
24+
25+
return (
26+
isImmediatePlanChange &&
27+
(totals.totalDueNow.amount > 0 ||
28+
(!!freeTrialEndsAt && environment.commerceSettings.billing.freeTrialRequiresPaymentMethod))
29+
);
30+
};

packages/types/src/commerceSettings.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import type { CommerceSettingsJSONSnapshot } from './snapshots';
55
export interface CommerceSettingsJSON extends ClerkResourceJSON {
66
billing: {
77
stripe_publishable_key: string;
8+
free_trial_requires_payment_method?: boolean;
89
organization: {
910
enabled: boolean;
1011
has_paid_plans: boolean;
@@ -19,6 +20,12 @@ export interface CommerceSettingsJSON extends ClerkResourceJSON {
1920
export interface CommerceSettingsResource extends ClerkResource {
2021
billing: {
2122
stripePublishableKey: string;
23+
/**
24+
* Whether payment methods are required when starting a free trial.
25+
* When false, users can start free trials without providing payment methods.
26+
* @default true
27+
*/
28+
freeTrialRequiresPaymentMethod: boolean;
2229
organization: {
2330
enabled: boolean;
2431
hasPaidPlans: boolean;

0 commit comments

Comments
 (0)