Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
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
2 changes: 1 addition & 1 deletion packages/clerk-js/bundlewatch.config.json
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
Expand Down
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
23 changes: 18 additions & 5 deletions packages/clerk-js/src/test/fixture-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
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
100 changes: 72 additions & 28 deletions packages/clerk-js/src/ui/components/Checkout/CheckoutForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -172,6 +173,11 @@ const useCheckoutMutations = () => {
});
};

const payWithoutPaymentMethod = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
return confirmCheckout({});
};

const addPaymentMethodAndPay = (ctx: { gateway: 'stripe'; paymentToken: string }) => confirmCheckout(ctx);

const payWithTestCard = () =>
Expand All @@ -184,6 +190,7 @@ const useCheckoutMutations = () => {
payWithExistingPaymentMethod,
addPaymentMethodAndPay,
payWithTestCard,
payWithoutPaymentMethod,
};
};

Expand Down Expand Up @@ -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<PaymentMethodSource>(() =>
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;
Expand All @@ -233,7 +243,7 @@ const CheckoutFormElementsInternal = () => {
>
{__BUILD_DISABLE_RHC__ ? null : (
<>
{paymentMethods.length > 0 && showPaymentMethods && (
{paymentMethods.length > 0 && showTabs && needsPaymentMethod && (
<SegmentedControl.Root
aria-label='Payment method source'
value={paymentMethodSource}
Expand All @@ -254,12 +264,15 @@ const CheckoutFormElementsInternal = () => {
</>
)}

{paymentMethodSource === 'existing' && (
<ExistingPaymentMethodForm
paymentMethods={paymentMethods}
totalDueNow={totals.totalDueNow}
/>
)}
{paymentMethodSource === 'existing' &&
(needsPaymentMethod ? (
<ExistingPaymentMethodForm
paymentMethods={paymentMethods}
totalDueNow={totals.totalDueNow}
/>
) : (
<FreeTrialButton />
))}

{__BUILD_DISABLE_RHC__ ? null : paymentMethodSource === 'new' && <AddPaymentMethodForCheckout />}
</Col>
Expand Down Expand Up @@ -344,6 +357,22 @@ const useSubmitLabel = () => {
return localizationKeys('billing.subscribe');
};

const FreeTrialButton = withCardStateProvider(() => {
const { for: _for } = useCheckoutContext();
const { payWithoutPaymentMethod } = useCheckoutMutations();
const card = useCardState();

return (
<Form
onSubmit={payWithoutPaymentMethod}
sx={formProps}
>
<Card.Alert>{card.error}</Card.Alert>
<CheckoutSubmitButton />
</Form>
);
});

const AddPaymentMethodForCheckout = withCardStateProvider(() => {
const { addPaymentMethodAndPay } = useCheckoutMutations();
const submitLabel = useSubmitLabel();
Expand All @@ -363,6 +392,32 @@ const AddPaymentMethodForCheckout = withCardStateProvider(() => {
);
});

const CheckoutSubmitButton = (props: PropsOfComponent<typeof Button>) => {
const card = useCardState();
const submitLabel = useSubmitLabel();

return (
<Button
type='submit'
colorScheme='primary'
size='sm'
textVariant={'buttonLarge'}
sx={{
width: '100%',
}}
isLoading={card.isLoading}
localizationKey={submitLabel}
{...props}
/>
);
};

const formProps: ThemableCssProp = t => ({
display: 'flex',
flexDirection: 'column',
rowGap: t.space.$4,
});

const ExistingPaymentMethodForm = withCardStateProvider(
({
totalDueNow,
Expand All @@ -371,9 +426,9 @@ const ExistingPaymentMethodForm = withCardStateProvider(
totalDueNow: BillingMoneyAmount;
paymentMethods: BillingPaymentMethodResource[];
}) => {
const submitLabel = useSubmitLabel();
const { checkout } = useCheckout();
const { paymentMethod, isImmediatePlanChange, freeTrialEndsAt } = checkout;
const environment = useEnvironment();

const { payWithExistingPaymentMethod } = useCheckoutMutations();
const card = useCardState();
Expand All @@ -395,16 +450,15 @@ const ExistingPaymentMethodForm = withCardStateProvider(
});
}, [paymentMethods]);

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

return (
<Form
onSubmit={payWithExistingPaymentMethod}
sx={t => ({
display: 'flex',
flexDirection: 'column',
rowGap: t.space.$4,
})}
sx={formProps}
>
{showPaymentMethods ? (
<Select
Expand Down Expand Up @@ -447,17 +501,7 @@ const ExistingPaymentMethodForm = withCardStateProvider(
/>
)}
<Card.Alert>{card.error}</Card.Alert>
<Button
type='submit'
colorScheme='primary'
size='sm'
textVariant={'buttonLarge'}
sx={{
width: '100%',
}}
isLoading={card.isLoading}
localizationKey={submitLabel}
/>
<CheckoutSubmitButton />
</Form>
);
},
Expand Down
Loading
Loading