From 94dbcc27a97347f1aa3c5699a94af8280ce8b54a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Malfait?= Date: Thu, 25 Jun 2026 04:49:31 +0200 Subject: [PATCH] feat(billing): embed credit card form in the add-card trial-end modal (#22125) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Context When a trialing workspace (trial without a credit card) clicks **Add Credit Card** from the "End trial period" banner or the AI-chat usage-limit banner, the modal currently redirects the browser to Stripe's hosted billing portal to collect the card. Since we already embed the Stripe Payment Element in onboarding, this brings the same in-app experience to the trial-end modal so the whole flow stays inside Twenty. ## Why the onboarding flow couldn't be reused as-is The onboarding embed (`createSubscriptionPaymentIntent` / `SubscriptionPaymentForm`) **creates a new subscription** with `payment_behavior: 'default_incomplete'`. In the trial-end case the customer **already has a trialing subscription**, so that path throws `BILLING_SUBSCRIPTION_INVALID`. The correct primitive here is a **SetupIntent** against the existing customer: collect + save the card, then end the trial. A standalone SetupIntent attaches the card to the customer but does **not** make it the default (the Stripe portal used to do that for us), so the trial-end invoice would have no payment method. The backend now backfills the customer default before charging. ## Changes **Backend** - `StripeCustomerService`: `createSetupIntent()` for an existing customer, and `ensureDefaultPaymentMethod()` which sets the customer default only when none is already set (won't clobber a portal-chosen default). - `BillingPortalWorkspaceService.createPaymentMethodSetupIntent()`: returns a SetupIntent client secret for the current non-canceled subscription's customer. - `BillingSubscriptionService.endTrialPeriod()`: ensures a default payment method before `trial_end: 'now'`. - New `createBillingPaymentMethodSetupIntent` mutation + `BillingSetupIntent` DTO; SDK schema snapshot synced. **Frontend** - `AddPaymentMethodForm`: Stripe Elements (`mode: 'setup'`), confirms with `redirect: 'if_required'` so the common card case stays in-app; 3DS still redirects and is finished by the existing `EndTrialAfterPaymentMethodEffect`. - `AddCreditCardModal`: hosts the embedded form. - Both trial-end banners (`InformationBannerEndTrialPeriod`, `AIChatNoMoreBillingCreditsBanner`) open the embedded modal instead of redirecting when no card is on file; the AI-chat path preserves its thread context in the 3DS return URL. ## Flow 1. User clicks **Add Credit Card** → embedded modal opens. 2. Card entered → `createBillingPaymentMethodSetupIntent` → `confirmSetup({ redirect: 'if_required' })`. 3. Non-3DS: confirms inline → `endSubscriptionTrialPeriod` → subscription active, no redirect. 4. 3DS: redirects to `?startSubscriptionAfterPaymentMethod=true` → existing effect finishes activation. 5. Self-hosted instances without a Stripe publishable key fall back to the existing portal redirect (the form renders an unavailable state). ## Notes for reviewers - The metadata GraphQL types were regenerated by hand (codegen needs a live `/metadata` server, which wasn't available in the authoring environment); a `graphql:generate --configuration=metadata` run against a live backend should be a no-op. - Local `typecheck`/`lint` could not be run in the authoring environment (dependency install was blocked); relying on CI to validate. - Scope is intentionally limited to the two trial-end banner modals. The Settings → Billing "update payment method" link still uses the Stripe portal. https://claude.ai/code/session_01VU7SfrSgaYWr2AhVL8DMfu --- _Generated by [Claude Code](https://claude.ai/code/session_01VU7SfrSgaYWr2AhVL8DMfu)_ Review in cubic --- .../src/metadata/generated/schema.graphql | 1 + .../src/metadata/generated/schema.ts | 2 + .../src/metadata/generated/types.ts | 3 + .../src/generated-metadata/graphql.ts | 7 + .../AIChatNoMoreBillingCreditsBanner.tsx | 33 ++- .../ai/hooks/useAiChatEndTrialPeriod.ts | 27 ++- .../InformationBannerEndTrialPeriod.tsx | 29 ++- .../billing/components/AddCreditCardModal.tsx | 83 ++++++++ .../components/AddPaymentMethodForm.tsx | 189 ++++++++++++++++++ .../createBillingPaymentMethodSetupIntent.ts | 9 + .../core-modules/billing/billing.resolver.ts | 13 ++ .../billing-portal.workspace-service.ts | 39 ++++ .../services/billing-subscription.service.ts | 4 + .../services/stripe-customer.service.ts | 37 ++++ 14 files changed, 450 insertions(+), 26 deletions(-) create mode 100644 packages/twenty-front/src/modules/settings/billing/components/AddCreditCardModal.tsx create mode 100644 packages/twenty-front/src/modules/settings/billing/components/AddPaymentMethodForm.tsx create mode 100644 packages/twenty-front/src/modules/settings/billing/graphql/mutations/createBillingPaymentMethodSetupIntent.ts diff --git a/packages/twenty-client-sdk/src/metadata/generated/schema.graphql b/packages/twenty-client-sdk/src/metadata/generated/schema.graphql index ab7131a2b9..b705dc68ec 100644 --- a/packages/twenty-client-sdk/src/metadata/generated/schema.graphql +++ b/packages/twenty-client-sdk/src/metadata/generated/schema.graphql @@ -3299,6 +3299,7 @@ type Mutation { 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! + createBillingPaymentMethodSetupIntent: BillingPaymentIntent! switchSubscriptionInterval: BillingUpdate! switchBillingPlan: BillingUpdate! cancelSwitchBillingPlan: BillingUpdate! diff --git a/packages/twenty-client-sdk/src/metadata/generated/schema.ts b/packages/twenty-client-sdk/src/metadata/generated/schema.ts index 0ecc185e6a..6dd53433fc 100644 --- a/packages/twenty-client-sdk/src/metadata/generated/schema.ts +++ b/packages/twenty-client-sdk/src/metadata/generated/schema.ts @@ -2817,6 +2817,7 @@ export interface Mutation { updateOneApplicationVariable: Scalars['Boolean'] checkoutSession: BillingSession createSubscriptionPaymentIntent: BillingPaymentIntent + createBillingPaymentMethodSetupIntent: BillingPaymentIntent switchSubscriptionInterval: BillingUpdate switchBillingPlan: BillingUpdate cancelSwitchBillingPlan: BillingUpdate @@ -6004,6 +6005,7 @@ export interface MutationGenqlSelection{ 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']} }) + createBillingPaymentMethodSetupIntent?: BillingPaymentIntentGenqlSelection switchSubscriptionInterval?: BillingUpdateGenqlSelection switchBillingPlan?: BillingUpdateGenqlSelection cancelSwitchBillingPlan?: BillingUpdateGenqlSelection diff --git a/packages/twenty-client-sdk/src/metadata/generated/types.ts b/packages/twenty-client-sdk/src/metadata/generated/types.ts index e3cde05778..99dbcb946f 100644 --- a/packages/twenty-client-sdk/src/metadata/generated/types.ts +++ b/packages/twenty-client-sdk/src/metadata/generated/types.ts @@ -7431,6 +7431,9 @@ export default { ] } ], + "createBillingPaymentMethodSetupIntent": [ + 152 + ], "switchSubscriptionInterval": [ 154 ], diff --git a/packages/twenty-front/src/generated-metadata/graphql.ts b/packages/twenty-front/src/generated-metadata/graphql.ts index 56d352c383..fb91d9dde4 100644 --- a/packages/twenty-front/src/generated-metadata/graphql.ts +++ b/packages/twenty-front/src/generated-metadata/graphql.ts @@ -2468,6 +2468,7 @@ export type Mutation = { createApplicationRegistration: CreateApplicationRegistration; createApplicationRegistrationVariable: ApplicationRegistrationVariable; createApprovedAccessDomain: ApprovedAccessDomain; + createBillingPaymentMethodSetupIntent: BillingPaymentIntent; createChatThread: AgentChatThread; createCommandMenuItem: CommandMenuItem; createDevelopmentApplication: DevelopmentApplication; @@ -7736,6 +7737,11 @@ export type CheckoutSessionMutationVariables = Exact<{ export type CheckoutSessionMutation = { __typename?: 'Mutation', checkoutSession: { __typename?: 'BillingSession', url?: string | null } }; +export type CreateBillingPaymentMethodSetupIntentMutationVariables = Exact<{ [key: string]: never; }>; + + +export type CreateBillingPaymentMethodSetupIntentMutation = { __typename?: 'Mutation', createBillingPaymentMethodSetupIntent: { __typename?: 'BillingPaymentIntent', clientSecret: string } }; + export type CreateSubscriptionPaymentIntentMutationVariables = Exact<{ recurringInterval: SubscriptionInterval; plan: BillingPlanKey; @@ -8823,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; 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; 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; +export const CreateBillingPaymentMethodSetupIntentDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"CreateBillingPaymentMethodSetupIntent"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"createBillingPaymentMethodSetupIntent"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"clientSecret"}}]}}]}}]} as unknown as DocumentNode; 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; 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; 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; diff --git a/packages/twenty-front/src/modules/ai/components/AIChatNoMoreBillingCreditsBanner.tsx b/packages/twenty-front/src/modules/ai/components/AIChatNoMoreBillingCreditsBanner.tsx index 96f6b97a56..aea77efd35 100644 --- a/packages/twenty-front/src/modules/ai/components/AIChatNoMoreBillingCreditsBanner.tsx +++ b/packages/twenty-front/src/modules/ai/components/AIChatNoMoreBillingCreditsBanner.tsx @@ -1,5 +1,6 @@ import { AiChatBanner } from '@/ai/components/AiChatBanner'; import { useAiChatEndTrialPeriod } from '@/ai/hooks/useAiChatEndTrialPeriod'; +import { AddCreditCardModal } from '@/settings/billing/components/AddCreditCardModal'; import { StartSubscriptionConfirmationModal } from '@/settings/billing/components/StartSubscriptionConfirmationModal'; import { useCreditUpgradeAction } from '@/settings/billing/hooks/useCreditUpgradeAction'; import { usePermissionFlagMap } from '@/settings/roles/hooks/usePermissionFlagMap'; @@ -28,8 +29,13 @@ export const AIChatNoMoreBillingCreditsBanner = () => { const isTrialing = subscriptionStatus === SubscriptionStatus.Trialing; - const { endTrialPeriodFromAiChat, isEndTrialLoading, hasPaymentMethod } = - useAiChatEndTrialPeriod(); + const { + endTrialPeriodFromAiChat, + startSubscriptionAfterPaymentMethodFromAiChat, + finalRedirectPath, + isEndTrialLoading, + hasPaymentMethod, + } = useAiChatEndTrialPeriod(); const { nextPrice, @@ -75,14 +81,21 @@ export const AIChatNoMoreBillingCreditsBanner = () => { (isTrialing && isEndTrialLoading) || (!isTrialing && isUpgrading) } /> - {isTrialing && ( - - )} + {isTrialing && + (hasPaymentMethod === false ? ( + + ) : ( + + ))} {!isTrialing && ( { const location = useLocation(); const currentAiChatThread = useAtomStateValue(currentAiChatThreadState); + const { openAskAiThread } = useOpenAskAiThread(); const { endTrialPeriod, isLoading } = useEndSubscriptionTrialPeriod(); const billingHasPaymentMethod = useAtomStateValue( billingHasPaymentMethodSelector, ); + const finalRedirectPath = buildAskAiThreadRedirectPath({ + pathname: location.pathname, + search: location.search, + threadId: currentAiChatThread, + }); + const endTrialPeriodFromAiChat = async () => { - await endTrialPeriod({ - finalRedirectPath: buildAskAiThreadRedirectPath({ - pathname: location.pathname, - search: location.search, - threadId: currentAiChatThread, - }), + await endTrialPeriod({ finalRedirectPath }); + }; + + const startSubscriptionAfterPaymentMethodFromAiChat = async () => { + const { success } = await endTrialPeriod({ + skipPaymentMethodRedirect: true, }); + + if (success && isNonEmptyString(currentAiChatThread)) { + openAskAiThread(currentAiChatThread); + } }; return { endTrialPeriodFromAiChat, + startSubscriptionAfterPaymentMethodFromAiChat, + finalRedirectPath, isEndTrialLoading: isLoading, hasPaymentMethod: billingHasPaymentMethod, }; diff --git a/packages/twenty-front/src/modules/information-banner/components/billing/InformationBannerEndTrialPeriod.tsx b/packages/twenty-front/src/modules/information-banner/components/billing/InformationBannerEndTrialPeriod.tsx index 8aaaab490a..33d934c823 100644 --- a/packages/twenty-front/src/modules/information-banner/components/billing/InformationBannerEndTrialPeriod.tsx +++ b/packages/twenty-front/src/modules/information-banner/components/billing/InformationBannerEndTrialPeriod.tsx @@ -1,4 +1,5 @@ import { InformationBanner } from '@/information-banner/components/InformationBanner'; +import { AddCreditCardModal } from '@/settings/billing/components/AddCreditCardModal'; import { StartSubscriptionConfirmationModal } from '@/settings/billing/components/StartSubscriptionConfirmationModal'; import { billingHasPaymentMethodSelector } from '@/settings/billing/states/billingHasPaymentMethodSelector'; import { useEndSubscriptionTrialPeriod } from '@/settings/billing/hooks/useEndSubscriptionTrialPeriod'; @@ -46,16 +47,24 @@ export const InformationBannerEndTrialPeriod = () => { } isButtonDisabled={isLoading} /> - {hasPermissionToEndTrialPeriod && ( - { - await endTrialPeriod(); - }} - loading={isLoading} - /> - )} + {hasPermissionToEndTrialPeriod && + (billingHasPaymentMethod === false ? ( + { + await endTrialPeriod({ skipPaymentMethodRedirect: true }); + }} + /> + ) : ( + { + await endTrialPeriod(); + }} + loading={isLoading} + /> + ))} ); }; diff --git a/packages/twenty-front/src/modules/settings/billing/components/AddCreditCardModal.tsx b/packages/twenty-front/src/modules/settings/billing/components/AddCreditCardModal.tsx new file mode 100644 index 0000000000..8558788a1d --- /dev/null +++ b/packages/twenty-front/src/modules/settings/billing/components/AddCreditCardModal.tsx @@ -0,0 +1,83 @@ +import { AddPaymentMethodForm } from '@/settings/billing/components/AddPaymentMethodForm'; +import { ModalStatefulWrapper } from '@/ui/layout/modal/components/ModalStatefulWrapper'; +import { useModal } from '@/ui/layout/modal/hooks/useModal'; +import { styled } from '@linaria/react'; +import { useLingui } from '@lingui/react/macro'; +import { Button } from 'twenty-ui/input'; +import { Section, SectionAlignment, SectionFontColor } from 'twenty-ui/layout'; +import { H1Title, H1TitleFontColor } from 'twenty-ui/typography'; +import { themeCssVariables } from 'twenty-ui/theme-constants'; + +type AddCreditCardModalProps = { + modalInstanceId: string; + finalRedirectPath?: string; + onPaymentMethodAdded: () => Promise; +}; + +const StyledCenteredTitle = styled.div` + text-align: center; +`; + +const StyledSectionContainer = styled.div` + margin-bottom: ${themeCssVariables.spacing[6]}; +`; + +const StyledCancelButtonContainer = styled.div` + margin-top: ${themeCssVariables.spacing[4]}; +`; + +export const AddCreditCardModal = ({ + modalInstanceId, + finalRedirectPath, + onPaymentMethodAdded, +}: AddCreditCardModalProps) => { + const { t } = useLingui(); + const { closeModal } = useModal(); + + const handlePaymentMethodAdded = async () => { + closeModal(modalInstanceId); + await onPaymentMethodAdded(); + }; + + return ( + + + + + +
+ {t`Add your credit card below. Once added, your subscription will start automatically.`} +
+
+ + +