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
+2
View File
@@ -62,6 +62,8 @@
"@scalar/api-reference-react": "^0.9.42",
"@sentry/react": "^10.51.0",
"@sniptt/guards": "^0.2.0",
"@stripe/react-stripe-js": "^6.6.0",
"@stripe/stripe-js": "^9.8.0",
"@tiptap/core": "3.4.2",
"@tiptap/extension-bold": "3.4.2",
"@tiptap/extension-document": "3.4.2",
@@ -566,6 +566,7 @@ export type Billing = {
__typename?: 'Billing';
billingUrl?: Maybe<Scalars['String']['output']>;
isBillingEnabled: Scalars['Boolean']['output'];
stripePublishableKey?: Maybe<Scalars['String']['output']>;
trialPeriods: Array<BillingTrialPeriod>;
};
@@ -616,6 +617,12 @@ export type BillingMeteredProduct = BillingProductDto & {
prices?: Maybe<Array<BillingPriceMetered>>;
};
export type BillingPaymentIntent = {
__typename?: 'BillingPaymentIntent';
clientSecret: Scalars['String']['output'];
paymentIntentType: Scalars['String']['output'];
};
export type BillingPlan = {
__typename?: 'BillingPlan';
baseProducts: Array<BillingLicensedProduct>;
@@ -2482,6 +2489,7 @@ export type Mutation = {
createPublicDomain: PublicDomain;
createSAMLIdentityProvider: SetupSso;
createSkill: Skill;
createSubscriptionPaymentIntent: BillingPaymentIntent;
createUnsubscribeTopic: UnsubscribeTopic;
createView: View;
createViewField: ViewField;
@@ -2866,6 +2874,15 @@ export type MutationCreateSkillArgs = {
};
export type MutationCreateSubscriptionPaymentIntentArgs = {
idempotencyKey: Scalars['String']['input'];
plan?: BillingPlanKey;
recurringInterval: SubscriptionInterval;
requirePaymentMethod?: Scalars['Boolean']['input'];
successUrlPath?: InputMaybe<Scalars['String']['input']>;
};
export type MutationCreateUnsubscribeTopicArgs = {
input: CreateUnsubscribeTopicInput;
};
@@ -7717,6 +7734,15 @@ export type CheckoutSessionMutationVariables = Exact<{
export type CheckoutSessionMutation = { __typename?: 'Mutation', checkoutSession: { __typename?: 'BillingSession', url?: string | null } };
export type CreateSubscriptionPaymentIntentMutationVariables = Exact<{
recurringInterval: SubscriptionInterval;
plan: BillingPlanKey;
idempotencyKey: Scalars['String']['input'];
}>;
export type CreateSubscriptionPaymentIntentMutation = { __typename?: 'Mutation', createSubscriptionPaymentIntent: { __typename?: 'BillingPaymentIntent', clientSecret: string, paymentIntentType: string } };
export type EndSubscriptionTrialPeriodMutationVariables = Exact<{ [key: string]: never; }>;
@@ -8803,6 +8829,7 @@ export const CancelSwitchBillingIntervalDocument = {"kind":"Document","definitio
export const CancelSwitchBillingPlanDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"CancelSwitchBillingPlan"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"cancelSwitchBillingPlan"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"currentBillingSubscription"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"CurrentBillingSubscriptionFragment"}}]}},{"kind":"Field","name":{"kind":"Name","value":"billingSubscriptions"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"BillingSubscriptionFragment"}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"BillingSubscriptionSchedulePhaseItemFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"BillingSubscriptionSchedulePhaseItem"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"price"}},{"kind":"Field","name":{"kind":"Name","value":"quantity"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"BillingSubscriptionSchedulePhaseFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"BillingSubscriptionSchedulePhase"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"start_date"}},{"kind":"Field","name":{"kind":"Name","value":"end_date"}},{"kind":"Field","name":{"kind":"Name","value":"items"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"BillingSubscriptionSchedulePhaseItemFragment"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"CurrentBillingSubscriptionFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"BillingSubscription"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"interval"}},{"kind":"Field","name":{"kind":"Name","value":"metadata"}},{"kind":"Field","name":{"kind":"Name","value":"currentPeriodEnd"}},{"kind":"Field","name":{"kind":"Name","value":"phases"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"BillingSubscriptionSchedulePhaseFragment"}}]}},{"kind":"Field","name":{"kind":"Name","value":"billingSubscriptionItems"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"hasReachedCurrentPeriodCap"}},{"kind":"Field","name":{"kind":"Name","value":"quantity"}},{"kind":"Field","name":{"kind":"Name","value":"stripePriceId"}},{"kind":"Field","name":{"kind":"Name","value":"billingProduct"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"images"}},{"kind":"Field","name":{"kind":"Name","value":"metadata"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"productKey"}},{"kind":"Field","name":{"kind":"Name","value":"planKey"}},{"kind":"Field","name":{"kind":"Name","value":"priceUsageBased"}}]}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"BillingSubscriptionFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"BillingSubscription"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"metadata"}},{"kind":"Field","name":{"kind":"Name","value":"phases"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"BillingSubscriptionSchedulePhaseFragment"}}]}}]}}]} as unknown as DocumentNode<CancelSwitchBillingPlanMutation, CancelSwitchBillingPlanMutationVariables>;
export const CancelSwitchResourceCreditPriceDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"CancelSwitchResourceCreditPrice"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"cancelSwitchResourceCreditPrice"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"currentBillingSubscription"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"CurrentBillingSubscriptionFragment"}}]}},{"kind":"Field","name":{"kind":"Name","value":"billingSubscriptions"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"BillingSubscriptionFragment"}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"BillingSubscriptionSchedulePhaseItemFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"BillingSubscriptionSchedulePhaseItem"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"price"}},{"kind":"Field","name":{"kind":"Name","value":"quantity"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"BillingSubscriptionSchedulePhaseFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"BillingSubscriptionSchedulePhase"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"start_date"}},{"kind":"Field","name":{"kind":"Name","value":"end_date"}},{"kind":"Field","name":{"kind":"Name","value":"items"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"BillingSubscriptionSchedulePhaseItemFragment"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"CurrentBillingSubscriptionFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"BillingSubscription"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"interval"}},{"kind":"Field","name":{"kind":"Name","value":"metadata"}},{"kind":"Field","name":{"kind":"Name","value":"currentPeriodEnd"}},{"kind":"Field","name":{"kind":"Name","value":"phases"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"BillingSubscriptionSchedulePhaseFragment"}}]}},{"kind":"Field","name":{"kind":"Name","value":"billingSubscriptionItems"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"hasReachedCurrentPeriodCap"}},{"kind":"Field","name":{"kind":"Name","value":"quantity"}},{"kind":"Field","name":{"kind":"Name","value":"stripePriceId"}},{"kind":"Field","name":{"kind":"Name","value":"billingProduct"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"images"}},{"kind":"Field","name":{"kind":"Name","value":"metadata"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"productKey"}},{"kind":"Field","name":{"kind":"Name","value":"planKey"}},{"kind":"Field","name":{"kind":"Name","value":"priceUsageBased"}}]}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"BillingSubscriptionFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"BillingSubscription"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"metadata"}},{"kind":"Field","name":{"kind":"Name","value":"phases"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"BillingSubscriptionSchedulePhaseFragment"}}]}}]}}]} as unknown as DocumentNode<CancelSwitchResourceCreditPriceMutation, CancelSwitchResourceCreditPriceMutationVariables>;
export const CheckoutSessionDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"CheckoutSession"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"recurringInterval"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"SubscriptionInterval"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"successUrlPath"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"plan"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"BillingPlanKey"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"requirePaymentMethod"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Boolean"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"checkoutSession"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"recurringInterval"},"value":{"kind":"Variable","name":{"kind":"Name","value":"recurringInterval"}}},{"kind":"Argument","name":{"kind":"Name","value":"successUrlPath"},"value":{"kind":"Variable","name":{"kind":"Name","value":"successUrlPath"}}},{"kind":"Argument","name":{"kind":"Name","value":"plan"},"value":{"kind":"Variable","name":{"kind":"Name","value":"plan"}}},{"kind":"Argument","name":{"kind":"Name","value":"requirePaymentMethod"},"value":{"kind":"Variable","name":{"kind":"Name","value":"requirePaymentMethod"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"url"}}]}}]}}]} as unknown as DocumentNode<CheckoutSessionMutation, CheckoutSessionMutationVariables>;
export const CreateSubscriptionPaymentIntentDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"CreateSubscriptionPaymentIntent"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"recurringInterval"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"SubscriptionInterval"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"plan"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"BillingPlanKey"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"idempotencyKey"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"createSubscriptionPaymentIntent"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"recurringInterval"},"value":{"kind":"Variable","name":{"kind":"Name","value":"recurringInterval"}}},{"kind":"Argument","name":{"kind":"Name","value":"plan"},"value":{"kind":"Variable","name":{"kind":"Name","value":"plan"}}},{"kind":"Argument","name":{"kind":"Name","value":"idempotencyKey"},"value":{"kind":"Variable","name":{"kind":"Name","value":"idempotencyKey"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"clientSecret"}},{"kind":"Field","name":{"kind":"Name","value":"paymentIntentType"}}]}}]}}]} as unknown as DocumentNode<CreateSubscriptionPaymentIntentMutation, CreateSubscriptionPaymentIntentMutationVariables>;
export const EndSubscriptionTrialPeriodDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"EndSubscriptionTrialPeriod"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"endSubscriptionTrialPeriod"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"hasPaymentMethod"}},{"kind":"Field","name":{"kind":"Name","value":"billingPortalUrl"}}]}}]}}]} as unknown as DocumentNode<EndSubscriptionTrialPeriodMutation, EndSubscriptionTrialPeriodMutationVariables>;
export const SetResourceCreditSubscriptionPriceDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"SetResourceCreditSubscriptionPrice"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"priceId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"setResourceCreditSubscriptionPrice"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"priceId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"priceId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"currentBillingSubscription"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"CurrentBillingSubscriptionFragment"}}]}},{"kind":"Field","name":{"kind":"Name","value":"billingSubscriptions"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"BillingSubscriptionFragment"}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"BillingSubscriptionSchedulePhaseItemFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"BillingSubscriptionSchedulePhaseItem"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"price"}},{"kind":"Field","name":{"kind":"Name","value":"quantity"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"BillingSubscriptionSchedulePhaseFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"BillingSubscriptionSchedulePhase"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"start_date"}},{"kind":"Field","name":{"kind":"Name","value":"end_date"}},{"kind":"Field","name":{"kind":"Name","value":"items"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"BillingSubscriptionSchedulePhaseItemFragment"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"CurrentBillingSubscriptionFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"BillingSubscription"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"interval"}},{"kind":"Field","name":{"kind":"Name","value":"metadata"}},{"kind":"Field","name":{"kind":"Name","value":"currentPeriodEnd"}},{"kind":"Field","name":{"kind":"Name","value":"phases"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"BillingSubscriptionSchedulePhaseFragment"}}]}},{"kind":"Field","name":{"kind":"Name","value":"billingSubscriptionItems"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"hasReachedCurrentPeriodCap"}},{"kind":"Field","name":{"kind":"Name","value":"quantity"}},{"kind":"Field","name":{"kind":"Name","value":"stripePriceId"}},{"kind":"Field","name":{"kind":"Name","value":"billingProduct"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"images"}},{"kind":"Field","name":{"kind":"Name","value":"metadata"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"productKey"}},{"kind":"Field","name":{"kind":"Name","value":"planKey"}},{"kind":"Field","name":{"kind":"Name","value":"priceUsageBased"}}]}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"BillingSubscriptionFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"BillingSubscription"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"metadata"}},{"kind":"Field","name":{"kind":"Name","value":"phases"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"BillingSubscriptionSchedulePhaseFragment"}}]}}]}}]} as unknown as DocumentNode<SetResourceCreditSubscriptionPriceMutation, SetResourceCreditSubscriptionPriceMutationVariables>;
export const SwitchBillingPlanDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"SwitchBillingPlan"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"switchBillingPlan"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"currentBillingSubscription"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"CurrentBillingSubscriptionFragment"}}]}},{"kind":"Field","name":{"kind":"Name","value":"billingSubscriptions"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"BillingSubscriptionFragment"}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"BillingSubscriptionSchedulePhaseItemFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"BillingSubscriptionSchedulePhaseItem"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"price"}},{"kind":"Field","name":{"kind":"Name","value":"quantity"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"BillingSubscriptionSchedulePhaseFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"BillingSubscriptionSchedulePhase"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"start_date"}},{"kind":"Field","name":{"kind":"Name","value":"end_date"}},{"kind":"Field","name":{"kind":"Name","value":"items"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"BillingSubscriptionSchedulePhaseItemFragment"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"CurrentBillingSubscriptionFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"BillingSubscription"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"interval"}},{"kind":"Field","name":{"kind":"Name","value":"metadata"}},{"kind":"Field","name":{"kind":"Name","value":"currentPeriodEnd"}},{"kind":"Field","name":{"kind":"Name","value":"phases"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"BillingSubscriptionSchedulePhaseFragment"}}]}},{"kind":"Field","name":{"kind":"Name","value":"billingSubscriptionItems"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"hasReachedCurrentPeriodCap"}},{"kind":"Field","name":{"kind":"Name","value":"quantity"}},{"kind":"Field","name":{"kind":"Name","value":"stripePriceId"}},{"kind":"Field","name":{"kind":"Name","value":"billingProduct"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"images"}},{"kind":"Field","name":{"kind":"Name","value":"metadata"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"productKey"}},{"kind":"Field","name":{"kind":"Name","value":"planKey"}},{"kind":"Field","name":{"kind":"Name","value":"priceUsageBased"}}]}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"BillingSubscriptionFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"BillingSubscription"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"metadata"}},{"kind":"Field","name":{"kind":"Name","value":"phases"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"BillingSubscriptionSchedulePhaseFragment"}}]}}]}}]} as unknown as DocumentNode<SwitchBillingPlanMutation, SwitchBillingPlanMutationVariables>;
@@ -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;
};
@@ -1,5 +1,5 @@
import { styled } from '@linaria/react';
import { useCallback, useEffect, useState } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { Logo } from '@/auth/components/Logo';
import { SubTitle } from '@/auth/components/SubTitle';
@@ -106,15 +106,20 @@ export const WorkspaceActivation = () => {
setNextOnboardingStatus,
]);
const [hasTriggered, setHasTriggered] = useState(false);
// Guard the one-shot trigger with a ref, not state: a ref mutation is
// synchronous and survives StrictMode's double-invocation of effects, whereas
// a state flag stays false in the second (same-closure) invocation and fires
// a second concurrent activation that trips the server's lock.
// oxlint-disable-next-line twenty/no-state-useref
const hasTriggeredRef = useRef(false);
useEffect(() => {
if (hasTriggered || !isDefined(currentWorkspace)) {
if (hasTriggeredRef.current || !isDefined(currentWorkspace)) {
return;
}
setHasTriggered(true);
hasTriggeredRef.current = true;
void activate();
}, [activate, hasTriggered, currentWorkspace]);
}, [activate, currentWorkspace]);
return (
<ModalContent isVerticallyCentered isHorizontallyCentered>
@@ -4,8 +4,7 @@ import { Title } from '@/auth/components/Title';
import { useAuth } from '@/auth/hooks/useAuth';
import { billingCheckoutSessionState } from '@/auth/states/billingCheckoutSessionState';
import { calendarBookingPageIdState } from '@/client-config/states/calendarBookingPageIdState';
import { SubscriptionBenefit } from '@/settings/billing/components/SubscriptionBenefit';
import { SubscriptionPrice } from '@/settings/billing/components/SubscriptionPrice';
import { SubscriptionPaymentForm } from '@/settings/billing/components/SubscriptionPaymentForm';
import { TrialCard } from '@/settings/billing/components/TrialCard';
import { useBaseLicensedPriceByPlanKeyAndInterval } from '@/settings/billing/hooks/useBaseLicensedPriceByPlanKeyAndInterval';
import { useBaseProductByPlanKey } from '@/settings/billing/hooks/useBaseProductByPlanKey';
@@ -20,60 +19,20 @@ import { Loader } from 'twenty-ui/feedback';
import { CardPicker, MainButton } from 'twenty-ui/input';
import { CAL_LINK, ClickToActionLink } from 'twenty-ui/navigation';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { BillingPlanKey, type Billing } from '~/generated-metadata/graphql';
const StyledSubscriptionContainer = styled.div<{
withLongerMarginBottom: boolean;
}>`
background-color: ${themeCssVariables.background.secondary};
border: 1px solid ${themeCssVariables.border.color.medium};
border-radius: ${themeCssVariables.border.radius.md};
display: flex;
flex-direction: column;
margin: ${themeCssVariables.spacing[8]} 0
${({ withLongerMarginBottom }) =>
withLongerMarginBottom
? themeCssVariables.spacing[8]
: themeCssVariables.spacing[2]};
width: 100%;
`;
const StyledSubscriptionPriceContainer = styled.div`
align-items: center;
border-bottom: 1px solid ${themeCssVariables.border.color.light};
display: flex;
flex-direction: column;
margin: ${themeCssVariables.spacing[4]} ${themeCssVariables.spacing[3]} 0
${themeCssVariables.spacing[4]};
padding-bottom: ${themeCssVariables.spacing[3]};
`;
const StyledBenefitsContainer = styled.div`
box-sizing: border-box;
display: flex;
flex-direction: column;
gap: 16px;
padding: ${themeCssVariables.spacing[4]} ${themeCssVariables.spacing[3]};
width: 100%;
`;
const StyledOrganizationBenefitsContainer = styled.div`
border-bottom: 1px solid ${themeCssVariables.border.color.light};
box-sizing: border-box;
display: flex;
flex-direction: column;
gap: 16px;
padding: ${themeCssVariables.spacing[4]} ${themeCssVariables.spacing[3]};
width: 100%;
`;
import { type Billing } from '~/generated-metadata/graphql';
const StyledChooseTrialContainer = styled.div`
display: flex;
flex-direction: row;
gap: ${themeCssVariables.spacing[2]};
margin-bottom: ${themeCssVariables.spacing[8]};
margin-bottom: ${themeCssVariables.spacing[4]};
margin-top: ${themeCssVariables.spacing[8]};
width: 100%;
> * {
flex: 1 1 0;
min-width: 0;
}
`;
const StyledLinkGroup = styled.div`
@@ -114,40 +73,6 @@ export const ChooseYourPlanContent = ({ billing }: { billing: Billing }) => {
const currentPlanKey = billingCheckoutSession.plan;
const getPlanBenefits = (planKey: BillingPlanKey) => {
if (planKey === BillingPlanKey.ENTERPRISE) {
return {
organizationBenefits: [
t`SSO (SAML / OIDC)`,
t`20,000 workflow node executions`,
t`Priority support`,
],
standardBenefits: [
t`Full access`,
t`Unlimited contacts`,
t`Email integration`,
t`Custom objects`,
t`API & Webhooks`,
],
};
}
return {
organizationBenefits: [],
standardBenefits: [
t`Full access`,
t`Unlimited contacts`,
t`Email integration`,
t`Custom objects`,
t`API & Webhooks`,
t`50,000 workflow node executions`,
],
};
};
const { organizationBenefits, standardBenefits } =
getPlanBenefits(currentPlanKey);
const baseProduct = getBaseProductByPlanKey(currentPlanKey);
const baseProductPrice = getBaseLicensedPriceByPlanKeyAndInterval(
currentPlanKey,
@@ -184,14 +109,6 @@ export const ChooseYourPlanContent = ({ billing }: { billing: Billing }) => {
};
};
const planChangeLink = (plan: BillingPlanKey) => {
const interval = billingCheckoutSession.interval;
const requirePaymentMethod = billingCheckoutSession.requirePaymentMethod;
return AppPath.PlanRequired.concat(
`?billingCheckoutSession={%22plan%22:%22${plan}%22,%22interval%22:%22${interval}%22,%22requirePaymentMethod%22:${requirePaymentMethod}}`,
);
};
const { signOut } = useAuth();
const withCreditCardTrialPeriodDuration = withCreditCardTrialPeriod?.duration;
@@ -212,28 +129,6 @@ export const ChooseYourPlanContent = ({ billing }: { billing: Billing }) => {
</SubTitle>
)
)}
<StyledSubscriptionContainer
withLongerMarginBottom={!hasWithoutCreditCardTrialPeriod}
>
<StyledSubscriptionPriceContainer>
<SubscriptionPrice
type={baseProductPrice.recurringInterval}
price={baseProductPrice.unitAmount / 100}
/>
</StyledSubscriptionPriceContainer>
{organizationBenefits.length > 0 && (
<StyledOrganizationBenefitsContainer>
{organizationBenefits.map((benefit) => (
<SubscriptionBenefit key={benefit}>{benefit}</SubscriptionBenefit>
))}
</StyledOrganizationBenefitsContainer>
)}
<StyledBenefitsContainer>
{standardBenefits.map((benefit) => (
<SubscriptionBenefit key={benefit}>{benefit}</SubscriptionBenefit>
))}
</StyledBenefitsContainer>
</StyledSubscriptionContainer>
{hasWithoutCreditCardTrialPeriod && (
<StyledChooseTrialContainer>
{billing.trialPeriods.map((trialPeriod) => (
@@ -255,13 +150,21 @@ export const ChooseYourPlanContent = ({ billing }: { billing: Billing }) => {
))}
</StyledChooseTrialContainer>
)}
<MainButton
title={t`Continue`}
onClick={handleCheckoutSession}
width={200}
Icon={() => isSubmitting && <Loader />}
disabled={isSubmitting}
/>
{billingCheckoutSession.requirePaymentMethod ? (
<SubscriptionPaymentForm
plan={billingCheckoutSession.plan}
recurringInterval={billingCheckoutSession.interval}
amount={baseProductPrice.unitAmount}
/>
) : (
<MainButton
title={t`Continue`}
onClick={handleCheckoutSession}
width={200}
Icon={() => isSubmitting && <Loader />}
disabled={isSubmitting}
/>
)}
<StyledLinkGroup>
<ClickToActionLink onClick={signOut}>
<Trans>Log out</Trans>
@@ -274,16 +177,6 @@ export const ChooseYourPlanContent = ({ billing }: { billing: Billing }) => {
>
<Trans>Book a Call</Trans>
</ClickToActionLink>
<span />
{currentPlanKey === BillingPlanKey.PRO ? (
<ClickToActionLink href={planChangeLink(BillingPlanKey.ENTERPRISE)}>
<Trans>Organization plan</Trans>
</ClickToActionLink>
) : (
<ClickToActionLink href={planChangeLink(BillingPlanKey.PRO)}>
<Trans>Pro plan</Trans>
</ClickToActionLink>
)}
</StyledLinkGroup>
</>
);
@@ -12,6 +12,7 @@ import { type AuthContextUser } from 'src/engine/core-modules/auth/types/auth-co
import { BillingEndTrialPeriodDTO } from 'src/engine/core-modules/billing/dtos/billing-end-trial-period.dto';
import { BillingResourceCreditUsageDTO } from 'src/engine/core-modules/billing/dtos/billing-resource-credit-usage.dto';
import { BillingPlanDTO } from 'src/engine/core-modules/billing/dtos/billing-plan.dto';
import { BillingPaymentIntentDTO } from 'src/engine/core-modules/billing/dtos/billing-payment-intent.dto';
import { BillingSessionDTO } from 'src/engine/core-modules/billing/dtos/billing-session.dto';
import { BillingUpdateDTO } from 'src/engine/core-modules/billing/dtos/billing-update.dto';
import { BillingCheckoutSessionInput } from 'src/engine/core-modules/billing/dtos/inputs/billing-checkout-session.input';
@@ -143,6 +144,40 @@ export class BillingResolver {
}
}
@Mutation(() => BillingPaymentIntentDTO)
@UseGuards(WorkspaceAuthGuard, UserAuthGuard, NoPermissionGuard)
async createSubscriptionPaymentIntent(
@AuthWorkspace() workspace: WorkspaceEntity,
@AuthUser() user: AuthContextUser,
@AuthUserWorkspaceId() userWorkspaceId: string,
@Args() { recurringInterval, plan }: BillingCheckoutSessionInput,
@Args('idempotencyKey', { type: () => String }) idempotencyKey: string,
@AuthApiKey() apiKey?: ApiKeyEntity,
): Promise<BillingPaymentIntentDTO> {
await this.validateCanCheckoutSessionPermissionOrThrow({
workspaceId: workspace.id,
userWorkspaceId,
apiKeyId: apiKey?.id,
workspaceActivationStatus: workspace.activationStatus,
});
const resolvedPlan = plan ?? BillingPlanKey.PRO;
const billingPricesPerPlan =
await this.billingPlanService.getPricesPerPlanByInterval({
planKey: resolvedPlan,
interval: recurringInterval,
});
return this.billingPortalWorkspaceService.createSubscriptionPaymentIntent({
user,
workspace,
plan: resolvedPlan,
billingPricesPerPlan,
idempotencyKey,
});
}
@Mutation(() => BillingUpdateDTO)
@UseGuards(
WorkspaceAuthGuard,
@@ -0,0 +1,12 @@
/* @license Enterprise */
import { Field, ObjectType } from '@nestjs/graphql';
@ObjectType('BillingPaymentIntent')
export class BillingPaymentIntentDTO {
@Field(() => String)
clientSecret: string;
@Field(() => String)
paymentIntentType: string;
}
@@ -72,8 +72,7 @@ export class BillingPortalWorkspaceService {
stripeCustomerId: customer?.stripeCustomerId,
plan,
requirePaymentMethod,
withTrialPeriod:
!isDefined(customer) || customer.billingSubscriptions.length === 0,
withTrialPeriod: this.isCustomerEligibleForTrialPeriod(customer),
});
assertIsDefinedOrThrow(
@@ -122,8 +121,7 @@ export class BillingPortalWorkspaceService {
stripeCustomerId: customer?.stripeCustomerId,
plan,
requirePaymentMethod,
withTrialPeriod:
!isDefined(customer) || customer.billingSubscriptions.length === 0,
withTrialPeriod: this.isCustomerEligibleForTrialPeriod(customer),
});
await this.billingSubscriptionService.syncSubscriptionToDatabase(
@@ -134,6 +132,158 @@ export class BillingPortalWorkspaceService {
return successUrl;
}
async createSubscriptionPaymentIntent({
user,
workspace,
billingPricesPerPlan,
plan,
idempotencyKey,
}: BillingPortalCheckoutSessionParameters & {
idempotencyKey: string;
}): Promise<{
clientSecret: string;
paymentIntentType: string;
}> {
const { customer, stripeSubscriptionLineItems } =
await this.prepareSubscriptionParameters({
workspace,
billingPricesPerPlan,
});
const resumablePaymentIntent =
await this.findResumableSubscriptionPaymentIntent(customer);
if (isDefined(resumablePaymentIntent)) {
return resumablePaymentIntent;
}
const stripeSubscription =
await this.stripeCheckoutService.createSubscriptionWithPaymentMethodCollection(
{
user,
workspace,
stripeSubscriptionLineItems,
stripeCustomerId: customer?.stripeCustomerId,
plan,
withTrialPeriod: this.isCustomerEligibleForTrialPeriod(customer),
idempotencyKey,
},
);
await this.billingSubscriptionService.syncSubscriptionToDatabase(
workspace.id,
stripeSubscription.id,
);
const paymentIntent =
this.extractSubscriptionClientSecret(stripeSubscription);
return paymentIntent;
}
// A failed earlier attempt leaves an incomplete subscription; it must not
// count, or a retry would be charged immediately instead of getting the
// trial. Only a real (non-incomplete) subscription blocks a new trial.
private isCustomerEligibleForTrialPeriod(
customer: BillingCustomerEntity | null,
): boolean {
return (
!isDefined(customer) ||
!customer.billingSubscriptions.some(
(subscription) =>
subscription.status !== SubscriptionStatus.Incomplete &&
subscription.status !== SubscriptionStatus.IncompleteExpired,
)
);
}
private async findResumableSubscriptionPaymentIntent(
customer: BillingCustomerEntity | null,
): Promise<{ clientSecret: string; paymentIntentType: string } | null> {
const existingSubscription = customer?.billingSubscriptions?.find(
(subscription) => subscription.status !== SubscriptionStatus.Canceled,
);
if (!isDefined(existingSubscription)) {
return null;
}
const stripeSubscription =
await this.stripeCheckoutService.retrieveSubscriptionForResume(
existingSubscription.stripeSubscriptionId,
);
const paymentIntent = this.findSubscriptionClientSecret(stripeSubscription);
if (isDefined(paymentIntent)) {
return paymentIntent;
}
if (
stripeSubscription.status === 'incomplete' ||
stripeSubscription.status === 'incomplete_expired'
) {
return null;
}
throw new BillingException(
'Customer already has a non-canceled billing subscription',
BillingExceptionCode.BILLING_SUBSCRIPTION_INVALID,
);
}
private extractSubscriptionClientSecret(subscription: Stripe.Subscription): {
clientSecret: string;
paymentIntentType: string;
} {
const paymentIntent = this.findSubscriptionClientSecret(subscription);
if (!isDefined(paymentIntent)) {
throw new BillingException(
'Error: missing subscription client secret',
BillingExceptionCode.BILLING_STRIPE_ERROR,
);
}
return paymentIntent;
}
private findSubscriptionClientSecret(subscription: Stripe.Subscription): {
clientSecret: string;
paymentIntentType: string;
} | null {
const pendingSetupIntent = subscription.pending_setup_intent;
if (
isDefined(pendingSetupIntent) &&
typeof pendingSetupIntent !== 'string' &&
isDefined(pendingSetupIntent.client_secret)
) {
return {
clientSecret: pendingSetupIntent.client_secret,
paymentIntentType: 'setup',
};
}
const latestInvoice = subscription.latest_invoice;
const confirmationSecret =
isDefined(latestInvoice) && typeof latestInvoice !== 'string'
? latestInvoice.confirmation_secret
: undefined;
if (
isDefined(confirmationSecret) &&
isDefined(confirmationSecret.client_secret)
) {
return {
clientSecret: confirmationSecret.client_secret,
paymentIntentType: 'payment',
};
}
return null;
}
private async prepareSubscriptionParameters({
workspace,
billingPricesPerPlan,
@@ -52,16 +52,11 @@ export class StripeCheckoutService {
requirePaymentMethod?: boolean;
withTrialPeriod: boolean;
}): Promise<Stripe.Checkout.Session> {
if (!isDefined(stripeCustomerId)) {
const stripeCustomer =
await this.stripeCustomerService.createStripeCustomer(
user.email,
workspace.id,
workspace.displayName,
);
stripeCustomerId = stripeCustomer.id;
}
stripeCustomerId = await this.getOrCreateStripeCustomerId({
user,
workspace,
stripeCustomerId,
});
return await this.stripe.checkout.sessions.create({
line_items: stripeSubscriptionLineItems,
@@ -105,18 +100,12 @@ export class StripeCheckoutService {
requirePaymentMethod?: boolean;
withTrialPeriod: boolean;
}): Promise<Stripe.Subscription> {
if (!isDefined(stripeCustomerId)) {
const stripeCustomer =
await this.stripeCustomerService.createStripeCustomer(
user.email,
workspace.id,
workspace.displayName,
);
stripeCustomerId = await this.getOrCreateStripeCustomerId({
user,
workspace,
stripeCustomerId,
});
stripeCustomerId = stripeCustomer.id;
}
// Convert checkout session line items to subscription items format
const subscriptionItems: Stripe.SubscriptionCreateParams.Item[] =
stripeSubscriptionLineItems.map((lineItem) => ({
price: lineItem.price as string,
@@ -140,6 +129,88 @@ export class StripeCheckoutService {
return await this.stripe.subscriptions.create(subscriptionParams);
}
async createSubscriptionWithPaymentMethodCollection({
user,
workspace,
stripeSubscriptionLineItems,
stripeCustomerId,
plan = BillingPlanKey.PRO,
withTrialPeriod,
idempotencyKey,
}: {
user: AuthContextUser;
workspace: Pick<WorkspaceEntity, 'id' | 'displayName'>;
stripeSubscriptionLineItems: Stripe.Checkout.SessionCreateParams.LineItem[];
stripeCustomerId?: string;
plan?: BillingPlanKey;
withTrialPeriod: boolean;
idempotencyKey: string;
}): Promise<Stripe.Subscription> {
const customerId = await this.getOrCreateStripeCustomerId({
user,
workspace,
stripeCustomerId,
});
const subscriptionItems: Stripe.SubscriptionCreateParams.Item[] =
stripeSubscriptionLineItems.map((lineItem) => ({
price: lineItem.price as string,
quantity: lineItem.quantity,
}));
return await this.stripe.subscriptions.create(
{
customer: customerId,
items: subscriptionItems,
metadata: {
workspaceId: workspace.id,
plan,
},
payment_behavior: 'default_incomplete',
payment_settings: {
save_default_payment_method: 'on_subscription',
},
...this.getStripeSubscriptionTrialPeriodConfig(withTrialPeriod, true),
automatic_tax: { enabled: false },
expand: ['pending_setup_intent', 'latest_invoice.confirmation_secret'],
},
{
idempotencyKey: `onboarding-subscription-${workspace.id}-${idempotencyKey}`,
},
);
}
async retrieveSubscriptionForResume(
stripeSubscriptionId: string,
): Promise<Stripe.Subscription> {
return this.stripe.subscriptions.retrieve(stripeSubscriptionId, {
expand: ['pending_setup_intent', 'latest_invoice.confirmation_secret'],
});
}
private async getOrCreateStripeCustomerId({
user,
workspace,
stripeCustomerId,
}: {
user: AuthContextUser;
workspace: Pick<WorkspaceEntity, 'id' | 'displayName'>;
stripeCustomerId?: string;
}): Promise<string> {
if (isDefined(stripeCustomerId)) {
return stripeCustomerId;
}
const stripeCustomer =
await this.stripeCustomerService.createStripeCustomer(
user.email,
workspace.id,
workspace.displayName,
);
return stripeCustomer.id;
}
private getStripeSubscriptionTrialPeriodConfig(
withTrialPeriod: boolean,
requirePaymentMethod: boolean,
@@ -165,6 +165,9 @@ export class Billing {
@Field(() => String, { nullable: true })
billingUrl?: string;
@Field(() => String, { nullable: true })
stripePublishableKey?: string;
@Field(() => [BillingTrialPeriodDTO])
trialPeriods: BillingTrialPeriodDTO[];
}
@@ -74,6 +74,7 @@ describe('ClientConfigService', () => {
BILLING_PLAN_REQUIRED_LINK: 'https://billing.example.com',
BILLING_FREE_TRIAL_WITH_CREDIT_CARD_DURATION_IN_DAYS: 30,
BILLING_FREE_TRIAL_WITHOUT_CREDIT_CARD_DURATION_IN_DAYS: 7,
BILLING_STRIPE_PUBLISHABLE_KEY: 'pk_test_123',
AUTH_GOOGLE_ENABLED: true,
AUTH_PASSWORD_ENABLED: true,
AUTH_MICROSOFT_ENABLED: false,
@@ -121,6 +122,7 @@ describe('ClientConfigService', () => {
billing: {
isBillingEnabled: true,
billingUrl: 'https://billing.example.com',
stripePublishableKey: 'pk_test_123',
trialPeriods: [
{
duration: 30,
@@ -160,6 +160,9 @@ export class ClientConfigService {
billing: {
isBillingEnabled: this.twentyConfigService.get('IS_BILLING_ENABLED'),
billingUrl: this.twentyConfigService.get('BILLING_PLAN_REQUIRED_LINK'),
stripePublishableKey: this.twentyConfigService.get(
'BILLING_STRIPE_PUBLISHABLE_KEY',
),
trialPeriods: [
{
duration: this.twentyConfigService.get(
@@ -836,6 +836,15 @@ export class ConfigVariables {
@ValidateIf((env) => env.IS_BILLING_ENABLED === true)
BILLING_STRIPE_WEBHOOK_SECRET: string;
@ConfigVariablesMetadata({
group: ConfigVariablesGroup.BILLING_CONFIG,
description:
'Stripe publishable key for billing, exposed to the frontend to mount Stripe Elements',
type: ConfigVariableType.STRING,
})
@ValidateIf((env) => env.IS_BILLING_ENABLED === true)
BILLING_STRIPE_PUBLISHABLE_KEY: string;
@ConfigVariablesMetadata({
group: ConfigVariablesGroup.BILLING_CONFIG,
description:
+23 -1
View File
@@ -20864,6 +20864,26 @@ __metadata:
languageName: node
linkType: hard
"@stripe/react-stripe-js@npm:^6.6.0":
version: 6.6.0
resolution: "@stripe/react-stripe-js@npm:6.6.0"
dependencies:
prop-types: "npm:^15.7.2"
peerDependencies:
"@stripe/stripe-js": ">=9.5.0 <10.0.0"
react: ">=16.8.0 <20.0.0"
react-dom: ">=16.8.0 <20.0.0"
checksum: 10c0/fb093073d19efb15c18cd4b4007b886bfff4bbee17b270fabb86c82e1dad0377ebb15d58bab37c41c3ea01599fc0a5117747611226ac232e1350ebbfeecb17a7
languageName: node
linkType: hard
"@stripe/stripe-js@npm:^9.8.0":
version: 9.8.0
resolution: "@stripe/stripe-js@npm:9.8.0"
checksum: 10c0/90c473b938800a3c7f5794e052b22b2a277e1e61a1b54353a8117c3fd630e42cba2530d42ca12a9e06b674cd3ad5d74adf815b54763dc14726aeb05056592031
languageName: node
linkType: hard
"@svgr/babel-plugin-add-jsx-attribute@npm:8.0.0":
version: 8.0.0
resolution: "@svgr/babel-plugin-add-jsx-attribute@npm:8.0.0"
@@ -47138,7 +47158,7 @@ __metadata:
languageName: node
linkType: hard
"prop-types@npm:15.x, prop-types@npm:^15.6.1, prop-types@npm:^15.6.2, prop-types@npm:^15.8.1":
"prop-types@npm:15.x, prop-types@npm:^15.6.1, prop-types@npm:^15.6.2, prop-types@npm:^15.7.2, prop-types@npm:^15.8.1":
version: 15.8.1
resolution: "prop-types@npm:15.8.1"
dependencies:
@@ -54024,6 +54044,8 @@ __metadata:
"@storybook/addon-docs": "npm:^10.3.3"
"@storybook/addon-links": "npm:^10.3.3"
"@storybook/react-vite": "npm:^10.3.3"
"@stripe/react-stripe-js": "npm:^6.6.0"
"@stripe/stripe-js": "npm:^9.8.0"
"@swc/core": "npm:^1.15.11"
"@swc/jest": "npm:^0.2.39"
"@testing-library/dom": "npm:^10.4.0"