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
@@ -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>;
|
||||
|
||||
+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;
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
+12
@@ -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;
|
||||
}
|
||||
+154
-4
@@ -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,
|
||||
|
||||
+92
-21
@@ -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[];
|
||||
}
|
||||
|
||||
+2
@@ -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,
|
||||
|
||||
+3
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user