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:
Félix Malfait
2026-06-19 11:40:55 +02:00
committed by GitHub
parent 1e744a761d
commit adf6eb572b
21 changed files with 1253 additions and 669 deletions
@@ -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>
);
@@ -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;
};