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
@@ -1610,6 +1610,11 @@ type BillingPlan {
meteredProducts: [BillingMeteredProduct!]!
}
type BillingPaymentIntent {
clientSecret: String!
paymentIntentType: String!
}
type BillingSession {
url: String
}
@@ -1879,6 +1884,7 @@ enum ModelFamily {
type Billing {
isBillingEnabled: Boolean!
billingUrl: String
stripePublishableKey: String
trialPeriods: [BillingTrialPeriod!]!
}
@@ -3286,6 +3292,7 @@ type Mutation {
skipBookOnboardingStep: OnboardingStepSuccess!
updateOneApplicationVariable(key: String!, value: String!, applicationId: UUID!): Boolean!
checkoutSession(recurringInterval: SubscriptionInterval!, plan: BillingPlanKey! = PRO, requirePaymentMethod: Boolean! = true, successUrlPath: String): BillingSession!
createSubscriptionPaymentIntent(recurringInterval: SubscriptionInterval!, plan: BillingPlanKey! = PRO, requirePaymentMethod: Boolean! = true, successUrlPath: String, idempotencyKey: String!): BillingPaymentIntent!
switchSubscriptionInterval: BillingUpdate!
switchBillingPlan: BillingUpdate!
cancelSwitchBillingPlan: BillingUpdate!
@@ -1258,6 +1258,12 @@ export interface BillingPlan {
__typename: 'BillingPlan'
}
export interface BillingPaymentIntent {
clientSecret: Scalars['String']
paymentIntentType: Scalars['String']
__typename: 'BillingPaymentIntent'
}
export interface BillingSession {
url?: Scalars['String']
__typename: 'BillingSession'
@@ -1504,6 +1510,7 @@ export type ModelFamily = 'GPT' | 'CLAUDE' | 'GEMINI' | 'MISTRAL' | 'GROK'
export interface Billing {
isBillingEnabled: Scalars['Boolean']
billingUrl?: Scalars['String']
stripePublishableKey?: Scalars['String']
trialPeriods: BillingTrialPeriod[]
__typename: 'Billing'
}
@@ -2804,6 +2811,7 @@ export interface Mutation {
skipBookOnboardingStep: OnboardingStepSuccess
updateOneApplicationVariable: Scalars['Boolean']
checkoutSession: BillingSession
createSubscriptionPaymentIntent: BillingPaymentIntent
switchSubscriptionInterval: BillingUpdate
switchBillingPlan: BillingUpdate
cancelSwitchBillingPlan: BillingUpdate
@@ -4298,6 +4306,13 @@ export interface BillingPlanGenqlSelection{
__scalar?: boolean | number
}
export interface BillingPaymentIntentGenqlSelection{
clientSecret?: boolean | number
paymentIntentType?: boolean | number
__typename?: boolean | number
__scalar?: boolean | number
}
export interface BillingSessionGenqlSelection{
url?: boolean | number
__typename?: boolean | number
@@ -4547,6 +4562,7 @@ export interface ClientAiModelConfigGenqlSelection{
export interface BillingGenqlSelection{
isBillingEnabled?: boolean | number
billingUrl?: boolean | number
stripePublishableKey?: boolean | number
trialPeriods?: BillingTrialPeriodGenqlSelection
__typename?: boolean | number
__scalar?: boolean | number
@@ -5978,6 +5994,7 @@ export interface MutationGenqlSelection{
skipBookOnboardingStep?: OnboardingStepSuccessGenqlSelection
updateOneApplicationVariable?: { __args: {key: Scalars['String'], value: Scalars['String'], applicationId: Scalars['UUID']} }
checkoutSession?: (BillingSessionGenqlSelection & { __args: {recurringInterval: SubscriptionInterval, plan: BillingPlanKey, requirePaymentMethod: Scalars['Boolean'], successUrlPath?: (Scalars['String'] | null)} })
createSubscriptionPaymentIntent?: (BillingPaymentIntentGenqlSelection & { __args: {recurringInterval: SubscriptionInterval, plan: BillingPlanKey, requirePaymentMethod: Scalars['Boolean'], successUrlPath?: (Scalars['String'] | null), idempotencyKey: Scalars['String']} })
switchSubscriptionInterval?: BillingUpdateGenqlSelection
switchBillingPlan?: BillingUpdateGenqlSelection
cancelSwitchBillingPlan?: BillingUpdateGenqlSelection
@@ -7321,6 +7338,14 @@ export interface LogicFunctionLogsInput {applicationId?: (Scalars['UUID'] | null
const BillingPaymentIntent_possibleTypes: string[] = ['BillingPaymentIntent']
export const isBillingPaymentIntent = (obj?: { __typename?: any } | null): obj is BillingPaymentIntent => {
if (!obj?.__typename) throw new Error('__typename is missing in "isBillingPaymentIntent"')
return BillingPaymentIntent_possibleTypes.includes(obj.__typename)
}
const BillingSession_possibleTypes: string[] = ['BillingSession']
export const isBillingSession = (obj?: { __typename?: any } | null): obj is BillingSession => {
if (!obj?.__typename) throw new Error('__typename is missing in "isBillingSession"')
File diff suppressed because it is too large Load Diff