feat(billing): embed Stripe Payment Element in onboarding (#21759)
## What & why Replaces the hosted Stripe Checkout redirect on the onboarding "Choose your plan" step (credit-card trial) with an inline Stripe **Payment Element**, so users never leave the app to enter card details. ## How it works - **Frontend:** a deferred `<Elements mode="setup">` renders the Payment Element, themed via the Appearance API. On Continue: `elements.submit()` → `checkoutSession` mutation creates the trialing subscription server-side and returns its pending SetupIntent `clientSecret` → `stripe.confirmSetup()` confirms the card (handling 3DS) → redirect to the existing `/plan-required/payment-success`. - **Backend:** new `BILLING_STRIPE_PUBLISHABLE_KEY` config var exposed via `/client-config`; the card path creates the subscription with `payment_behavior: default_incomplete` + a free trial (so Stripe attaches a `pending_setup_intent`) and returns its client secret. The hosted-Checkout code path is removed. - The **no-credit-card** trial path is unchanged. - Billing address collection is **disabled** in the Payment Element to reduce friction; `automatic_tax` is correspondingly disabled (tax needs an address — collect it later, e.g. at conversion / via the billing portal). ## Required before this works 1. Set `BILLING_STRIPE_PUBLISHABLE_KEY` (`pk_…`) on the server (infra change pending). 2. Run `nx run twenty-front:graphql:generate --configuration=metadata` against a server exposing the updated schema (see inline note on the hand-authored document). 3. Verify in Stripe test mode: happy path, 3DS (`4000 0025 0000 3155`), a decline. ## Verified typecheck (front + server), oxlint + oxfmt clean, `client-config.service.spec` passing. Not run here: the app end-to-end / Stripe test mode and `graphql:generate` (no server/DB in the dev container). I've left self-review comments inline flagging cleanup opportunities plus a couple of architectural/tech-debt items. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01TxCfinXq7abSrbF7aTw2cA --- _Generated by [Claude Code](https://claude.ai/code/session_01TxCfinXq7abSrbF7aTw2cA)_ <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/21759?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. --> --------- Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
+190
@@ -0,0 +1,190 @@
|
||||
import { currentUserState } from '@/auth/states/currentUserState';
|
||||
import { useStripeAppearance } from '@/settings/billing/hooks/useStripeAppearance';
|
||||
import { useStripePromise } from '@/settings/billing/hooks/useStripePromise';
|
||||
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
|
||||
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
|
||||
import { CombinedGraphQLErrors } from '@apollo/client/errors';
|
||||
import { useMutation } from '@apollo/client/react';
|
||||
import { styled } from '@linaria/react';
|
||||
import { t } from '@lingui/core/macro';
|
||||
import {
|
||||
Elements,
|
||||
PaymentElement,
|
||||
useElements,
|
||||
useStripe,
|
||||
} from '@stripe/react-stripe-js';
|
||||
import { useState } from 'react';
|
||||
import { AppPath } from 'twenty-shared/types';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { Info, Loader } from 'twenty-ui/feedback';
|
||||
import { MainButton } from 'twenty-ui/input';
|
||||
import { themeCssVariables } from 'twenty-ui/theme-constants';
|
||||
import {
|
||||
type BillingPlanKey,
|
||||
type SubscriptionInterval,
|
||||
CreateSubscriptionPaymentIntentDocument,
|
||||
} from '~/generated-metadata/graphql';
|
||||
|
||||
type SubscriptionPaymentFormContentProps = {
|
||||
plan: BillingPlanKey;
|
||||
recurringInterval: SubscriptionInterval;
|
||||
};
|
||||
|
||||
type SubscriptionPaymentFormProps = SubscriptionPaymentFormContentProps & {
|
||||
amount: number;
|
||||
};
|
||||
|
||||
const StyledFormContainer = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: ${themeCssVariables.spacing[4]};
|
||||
margin-bottom: ${themeCssVariables.spacing[8]};
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const StyledButtonContainer = styled.div`
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
`;
|
||||
|
||||
const SubscriptionPaymentFormContent = ({
|
||||
plan,
|
||||
recurringInterval,
|
||||
}: SubscriptionPaymentFormContentProps) => {
|
||||
const stripe = useStripe();
|
||||
const elements = useElements();
|
||||
const { enqueueErrorSnackBar } = useSnackBar();
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const customerEmail = useAtomStateValue(currentUserState)?.email;
|
||||
|
||||
const [createSubscriptionPaymentIntent] = useMutation(
|
||||
CreateSubscriptionPaymentIntentDocument,
|
||||
);
|
||||
|
||||
const isStripeReady = isDefined(stripe) && isDefined(elements);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!isStripeReady) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
|
||||
try {
|
||||
const { error: submitError } = await elements.submit();
|
||||
if (isDefined(submitError)) {
|
||||
enqueueErrorSnackBar({
|
||||
message:
|
||||
submitError.message ??
|
||||
t`Your payment details are incomplete. Please review and retry.`,
|
||||
});
|
||||
setIsSubmitting(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const idempotencyKey = crypto.randomUUID();
|
||||
const { data } = await createSubscriptionPaymentIntent({
|
||||
variables: { recurringInterval, plan, idempotencyKey },
|
||||
});
|
||||
|
||||
const paymentIntent = data?.createSubscriptionPaymentIntent;
|
||||
if (!isDefined(paymentIntent?.clientSecret)) {
|
||||
enqueueErrorSnackBar({
|
||||
message: t`Subscription error. Please retry or contact Twenty team`,
|
||||
});
|
||||
setIsSubmitting(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const returnUrl = new URL(
|
||||
AppPath.PlanRequiredSuccess,
|
||||
window.location.origin,
|
||||
).toString();
|
||||
|
||||
const { error } =
|
||||
paymentIntent.paymentIntentType === 'setup'
|
||||
? await stripe.confirmSetup({
|
||||
elements,
|
||||
clientSecret: paymentIntent.clientSecret,
|
||||
confirmParams: { return_url: returnUrl },
|
||||
})
|
||||
: await stripe.confirmPayment({
|
||||
elements,
|
||||
clientSecret: paymentIntent.clientSecret,
|
||||
confirmParams: { return_url: returnUrl },
|
||||
});
|
||||
|
||||
if (isDefined(error)) {
|
||||
enqueueErrorSnackBar({
|
||||
message:
|
||||
error.message ??
|
||||
t`We couldn't confirm your payment method. Please retry.`,
|
||||
});
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
} catch (error) {
|
||||
if (CombinedGraphQLErrors.is(error)) {
|
||||
enqueueErrorSnackBar({ apolloError: error });
|
||||
} else {
|
||||
enqueueErrorSnackBar({
|
||||
message: t`Subscription error. Please retry or contact Twenty team`,
|
||||
});
|
||||
}
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<StyledFormContainer>
|
||||
<PaymentElement
|
||||
options={{
|
||||
defaultValues: isDefined(customerEmail)
|
||||
? { billingDetails: { email: customerEmail } }
|
||||
: undefined,
|
||||
}}
|
||||
/>
|
||||
<StyledButtonContainer>
|
||||
<MainButton
|
||||
title={t`Continue`}
|
||||
onClick={handleSubmit}
|
||||
width={200}
|
||||
Icon={() => (isSubmitting ? <Loader /> : null)}
|
||||
disabled={!isStripeReady || isSubmitting}
|
||||
/>
|
||||
</StyledButtonContainer>
|
||||
</StyledFormContainer>
|
||||
);
|
||||
};
|
||||
|
||||
export const SubscriptionPaymentForm = ({
|
||||
plan,
|
||||
recurringInterval,
|
||||
amount,
|
||||
}: SubscriptionPaymentFormProps) => {
|
||||
const stripePromise = useStripePromise();
|
||||
const appearance = useStripeAppearance();
|
||||
|
||||
if (!isDefined(stripePromise)) {
|
||||
return (
|
||||
<StyledFormContainer>
|
||||
<Info
|
||||
accent="danger"
|
||||
text={t`Card payment is currently unavailable. Please verify your Stripe configuration or contact your workspace admin.`}
|
||||
/>
|
||||
</StyledFormContainer>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Elements
|
||||
stripe={stripePromise}
|
||||
options={{ mode: 'subscription', amount, currency: 'usd', appearance }}
|
||||
>
|
||||
<SubscriptionPaymentFormContent
|
||||
plan={plan}
|
||||
recurringInterval={recurringInterval}
|
||||
/>
|
||||
</Elements>
|
||||
);
|
||||
};
|
||||
@@ -31,7 +31,7 @@ export const TrialCard = ({ duration, withCreditCard }: TrialCardProps) => {
|
||||
<StyledTrialCardContainer>
|
||||
<StyledTrialDurationContainer>{t`${duration} days trial`}</StyledTrialDurationContainer>
|
||||
<StyledCreditCardRequirementContainer>
|
||||
{withCreditCard ? t`With Credit Card` : t`Without Credit Card`}
|
||||
{withCreditCard ? t`With Credit Card` : t`No Credit Card`}
|
||||
</StyledCreditCardRequirementContainer>
|
||||
</StyledTrialCardContainer>
|
||||
);
|
||||
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
import { gql } from '@apollo/client';
|
||||
|
||||
export const CREATE_SUBSCRIPTION_PAYMENT_INTENT = gql`
|
||||
mutation CreateSubscriptionPaymentIntent(
|
||||
$recurringInterval: SubscriptionInterval!
|
||||
$plan: BillingPlanKey!
|
||||
$idempotencyKey: String!
|
||||
) {
|
||||
createSubscriptionPaymentIntent(
|
||||
recurringInterval: $recurringInterval
|
||||
plan: $plan
|
||||
idempotencyKey: $idempotencyKey
|
||||
) {
|
||||
clientSecret
|
||||
paymentIntentType
|
||||
}
|
||||
}
|
||||
`;
|
||||
@@ -0,0 +1,42 @@
|
||||
import { type Appearance } from '@stripe/stripe-js';
|
||||
import { useContext } from 'react';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { THEME_DARK, THEME_LIGHT } from 'twenty-ui/theme';
|
||||
import { ThemeContext } from 'twenty-ui/theme-constants';
|
||||
|
||||
// Stripe's Appearance API rejects CSS color(display-p3 ...) values, which is
|
||||
// how the Twenty theme stores colors; map them to sRGB so the PaymentElement
|
||||
// is themed instead of silently falling back to Stripe defaults.
|
||||
const toStripeColor = (color: string): string => {
|
||||
const match = color.match(
|
||||
/^color\(display-p3\s+([\d.]+)\s+([\d.]+)\s+([\d.]+)(?:\s*\/\s*([\d.]+))?\)$/,
|
||||
);
|
||||
|
||||
if (!isDefined(match)) {
|
||||
return color;
|
||||
}
|
||||
|
||||
const [, red, green, blue, alpha] = match;
|
||||
const toByte = (value: string) => Math.round(Number(value) * 255);
|
||||
const rgb = `${toByte(red)}, ${toByte(green)}, ${toByte(blue)}`;
|
||||
|
||||
// oxlint-disable-next-line twenty/no-hardcoded-colors
|
||||
return isDefined(alpha) ? `rgba(${rgb}, ${alpha})` : `rgb(${rgb})`;
|
||||
};
|
||||
|
||||
export const useStripeAppearance = (): Appearance => {
|
||||
const { colorScheme } = useContext(ThemeContext);
|
||||
const isDark = colorScheme === 'dark';
|
||||
const theme = isDark ? THEME_DARK : THEME_LIGHT;
|
||||
|
||||
return {
|
||||
theme: isDark ? 'night' : 'stripe',
|
||||
variables: {
|
||||
colorPrimary: toStripeColor(theme.color.blue),
|
||||
colorBackground: toStripeColor(theme.background.primary),
|
||||
colorText: toStripeColor(theme.font.color.primary),
|
||||
colorDanger: toStripeColor(theme.font.color.danger),
|
||||
borderRadius: '8px',
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,30 @@
|
||||
import { billingState } from '@/client-config/states/billingState';
|
||||
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
|
||||
import { type Stripe } from '@stripe/stripe-js';
|
||||
import { loadStripe } from '@stripe/stripe-js/pure';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
const stripePromiseByKey = new Map<string, Promise<Stripe | null>>();
|
||||
|
||||
const getStripePromise = (publishableKey: string): Promise<Stripe | null> => {
|
||||
const existingPromise = stripePromiseByKey.get(publishableKey);
|
||||
|
||||
if (isDefined(existingPromise)) {
|
||||
return existingPromise;
|
||||
}
|
||||
|
||||
const stripePromise = loadStripe(publishableKey);
|
||||
|
||||
stripePromiseByKey.set(publishableKey, stripePromise);
|
||||
|
||||
return stripePromise;
|
||||
};
|
||||
|
||||
export const useStripePromise = (): Promise<Stripe | null> | null => {
|
||||
const billing = useAtomStateValue(billingState);
|
||||
const publishableKey = billing?.stripePublishableKey;
|
||||
|
||||
return isDefined(publishableKey) && publishableKey !== ''
|
||||
? getStripePromise(publishableKey)
|
||||
: null;
|
||||
};
|
||||
Reference in New Issue
Block a user