Skip to content

Commit 1d2ec0b

Browse files
authored
feat(clerk-js): Make payment method optional for free trials (#7065)
1 parent 0d3c55a commit 1d2ec0b

File tree

4 files changed

+477
-83
lines changed

4 files changed

+477
-83
lines changed

.changeset/pretty-garlics-sing.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
---
2+
---

packages/clerk-js/src/test/fixture-helpers.ts

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -362,11 +362,24 @@ const createOrganizationSettingsFixtureHelpers = (environment: EnvironmentJSON)
362362

363363
const createBillingSettingsFixtureHelpers = (environment: EnvironmentJSON) => {
364364
const os = environment.commerce_settings.billing;
365-
const withBilling = () => {
366-
os.user.enabled = true;
367-
os.user.has_paid_plans = true;
368-
os.organization.enabled = true;
369-
os.organization.has_paid_plans = true;
365+
const withBilling = ({
366+
userEnabled = true,
367+
userHasPaidPlans = true,
368+
organizationEnabled = true,
369+
organizationHasPaidPlans = true,
370+
freeTrialRequiresPaymentMethod = true,
371+
}: {
372+
userEnabled?: boolean;
373+
userHasPaidPlans?: boolean;
374+
organizationEnabled?: boolean;
375+
organizationHasPaidPlans?: boolean;
376+
freeTrialRequiresPaymentMethod?: boolean;
377+
} = {}) => {
378+
os.user.enabled = userEnabled;
379+
os.user.has_paid_plans = userHasPaidPlans;
380+
os.organization.enabled = organizationEnabled;
381+
os.organization.has_paid_plans = organizationHasPaidPlans;
382+
os.free_trial_requires_payment_method = freeTrialRequiresPaymentMethod;
370383
};
371384

372385
return { withBilling };

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

Lines changed: 55 additions & 77 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { DevOnly } from '../../common/DevOnly';
1515
import { useCheckoutContext, useEnvironment, usePaymentMethods } from '../../contexts';
1616
import { Box, Button, Col, descriptors, Flex, Form, localizationKeys, Spinner, Text } from '../../customizables';
1717
import { ChevronUpDown, InformationCircle } from '../../icons';
18+
import type { PropsOfComponent, ThemableCssProp } from '../../styledSystem';
1819
import * as AddPaymentMethod from '../PaymentMethods/AddPaymentMethod';
1920
import { PaymentMethodRow } from '../PaymentMethods/PaymentMethodRow';
2021
import { SubscriptionBadge } from '../Subscriptions/badge';
@@ -138,7 +139,7 @@ export const CheckoutForm = withCardStateProvider(() => {
138139
});
139140

140141
const useCheckoutMutations = () => {
141-
const { for: _for, onSubscriptionComplete } = useCheckoutContext();
142+
const { onSubscriptionComplete } = useCheckoutContext();
142143
const { checkout } = useCheckout();
143144
const { id, confirm } = checkout;
144145
const card = useCardState();
@@ -172,6 +173,11 @@ const useCheckoutMutations = () => {
172173
});
173174
};
174175

176+
const payWithoutPaymentMethod = (e: React.FormEvent<HTMLFormElement>) => {
177+
e.preventDefault();
178+
return confirmCheckout({});
179+
};
180+
175181
const addPaymentMethodAndPay = (ctx: { gateway: 'stripe'; paymentToken: string }) => confirmCheckout(ctx);
176182

177183
const payWithTestCard = () =>
@@ -184,6 +190,7 @@ const useCheckoutMutations = () => {
184190
payWithExistingPaymentMethod,
185191
addPaymentMethodAndPay,
186192
payWithTestCard,
193+
payWithoutPaymentMethod,
187194
};
188195
};
189196

@@ -220,13 +227,9 @@ const CheckoutFormElementsInternal = () => {
220227
paymentMethods.length > 0 || __BUILD_DISABLE_RHC__ ? 'existing' : 'new',
221228
);
222229

223-
// Check if payment methods should be shown based on:
224-
// 1. Immediate plan change (not a downgrade)
225-
// 2. Either there's an amount due now OR it's a free trial that requires payment method
226-
const showPaymentMethods =
227-
isImmediatePlanChange &&
228-
(totals.totalDueNow.amount > 0 ||
229-
(!!freeTrialEndsAt && environment.commerceSettings.billing.freeTrialRequiresPaymentMethod));
230+
const isFreeTrial = Boolean(freeTrialEndsAt);
231+
const showTabs = isImmediatePlanChange && (totals.totalDueNow.amount > 0 || isFreeTrial);
232+
const needsPaymentMethod = !(isFreeTrial && !environment.commerceSettings.billing.freeTrialRequiresPaymentMethod);
230233

231234
if (!id) {
232235
return null;
@@ -240,7 +243,7 @@ const CheckoutFormElementsInternal = () => {
240243
>
241244
{__BUILD_DISABLE_RHC__ ? null : (
242245
<>
243-
{paymentMethods.length > 0 && showPaymentMethods && (
246+
{paymentMethods.length > 0 && showTabs && needsPaymentMethod && (
244247
<SegmentedControl.Root
245248
aria-label='Payment method source'
246249
value={paymentMethodSource}
@@ -261,18 +264,17 @@ const CheckoutFormElementsInternal = () => {
261264
</>
262265
)}
263266

264-
{showPaymentMethods && paymentMethodSource === 'existing' && (
265-
<ExistingPaymentMethodForm
266-
paymentMethods={paymentMethods}
267-
totalDueNow={totals.totalDueNow}
268-
/>
269-
)}
270-
271-
{__BUILD_DISABLE_RHC__
272-
? null
273-
: showPaymentMethods && paymentMethodSource === 'new' && <AddPaymentMethodForCheckout />}
267+
{paymentMethodSource === 'existing' &&
268+
(needsPaymentMethod ? (
269+
<ExistingPaymentMethodForm
270+
paymentMethods={paymentMethods}
271+
totalDueNow={totals.totalDueNow}
272+
/>
273+
) : (
274+
<FreeTrialButton />
275+
))}
274276

275-
{!showPaymentMethods && <FreeTrialButton />}
277+
{__BUILD_DISABLE_RHC__ ? null : paymentMethodSource === 'new' && <AddPaymentMethodForCheckout />}
276278
</Col>
277279
);
278280
};
@@ -356,52 +358,17 @@ const useSubmitLabel = () => {
356358
};
357359

358360
const FreeTrialButton = withCardStateProvider(() => {
359-
const { for: _for, onSubscriptionComplete } = useCheckoutContext();
360-
const submitLabel = useSubmitLabel();
361+
const { for: _for } = useCheckoutContext();
362+
const { payWithoutPaymentMethod } = useCheckoutMutations();
361363
const card = useCardState();
362-
const { checkout } = useCheckout();
363-
364-
const handleFreeTrialStart = async () => {
365-
card.setLoading();
366-
card.setError(undefined);
367-
368-
try {
369-
// For free trials without payment method requirement, we can confirm without payment details
370-
const { data, error } = await checkout.confirm({});
371-
372-
if (error) {
373-
handleError(error, [], card.setError);
374-
} else if (data) {
375-
onSubscriptionComplete?.();
376-
}
377-
} catch (error) {
378-
handleError(error, [], card.setError);
379-
} finally {
380-
card.setIdle();
381-
}
382-
};
383364

384365
return (
385366
<Form
386-
sx={t => ({
387-
display: 'flex',
388-
flexDirection: 'column',
389-
rowGap: t.space.$4,
390-
})}
367+
onSubmit={payWithoutPaymentMethod}
368+
sx={formProps}
391369
>
392370
<Card.Alert>{card.error}</Card.Alert>
393-
<Button
394-
type='button'
395-
colorScheme='primary'
396-
size='sm'
397-
textVariant={'buttonLarge'}
398-
sx={{
399-
width: '100%',
400-
}}
401-
isLoading={card.isLoading}
402-
localizationKey={submitLabel}
403-
onClick={handleFreeTrialStart}
404-
/>
371+
<CheckoutSubmitButton />
405372
</Form>
406373
);
407374
});
@@ -425,6 +392,32 @@ const AddPaymentMethodForCheckout = withCardStateProvider(() => {
425392
);
426393
});
427394

395+
const CheckoutSubmitButton = (props: PropsOfComponent<typeof Button>) => {
396+
const card = useCardState();
397+
const submitLabel = useSubmitLabel();
398+
399+
return (
400+
<Button
401+
type='submit'
402+
colorScheme='primary'
403+
size='sm'
404+
textVariant={'buttonLarge'}
405+
sx={{
406+
width: '100%',
407+
}}
408+
isLoading={card.isLoading}
409+
localizationKey={submitLabel}
410+
{...props}
411+
/>
412+
);
413+
};
414+
415+
const formProps: ThemableCssProp = t => ({
416+
display: 'flex',
417+
flexDirection: 'column',
418+
rowGap: t.space.$4,
419+
});
420+
428421
const ExistingPaymentMethodForm = withCardStateProvider(
429422
({
430423
totalDueNow,
@@ -433,7 +426,6 @@ const ExistingPaymentMethodForm = withCardStateProvider(
433426
totalDueNow: BillingMoneyAmount;
434427
paymentMethods: BillingPaymentMethodResource[];
435428
}) => {
436-
const submitLabel = useSubmitLabel();
437429
const { checkout } = useCheckout();
438430
const { paymentMethod, isImmediatePlanChange, freeTrialEndsAt } = checkout;
439431
const environment = useEnvironment();
@@ -466,11 +458,7 @@ const ExistingPaymentMethodForm = withCardStateProvider(
466458
return (
467459
<Form
468460
onSubmit={payWithExistingPaymentMethod}
469-
sx={t => ({
470-
display: 'flex',
471-
flexDirection: 'column',
472-
rowGap: t.space.$4,
473-
})}
461+
sx={formProps}
474462
>
475463
{showPaymentMethods ? (
476464
<Select
@@ -513,17 +501,7 @@ const ExistingPaymentMethodForm = withCardStateProvider(
513501
/>
514502
)}
515503
<Card.Alert>{card.error}</Card.Alert>
516-
<Button
517-
type='submit'
518-
colorScheme='primary'
519-
size='sm'
520-
textVariant={'buttonLarge'}
521-
sx={{
522-
width: '100%',
523-
}}
524-
isLoading={card.isLoading}
525-
localizationKey={submitLabel}
526-
/>
504+
<CheckoutSubmitButton />
527505
</Form>
528506
);
529507
},

0 commit comments

Comments
 (0)