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:
@@ -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
Reference in New Issue
Block a user