feat(billing): embed credit card form in the add-card trial-end modal (#22125)

## 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)_

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22125?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. -->
This commit is contained in:
Félix Malfait
2026-06-25 04:49:31 +02:00
committed by GitHub
parent 179ab2f066
commit 94dbcc27a9
14 changed files with 450 additions and 26 deletions
@@ -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!
@@ -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
@@ -7431,6 +7431,9 @@ export default {
]
}
],
"createBillingPaymentMethodSetupIntent": [
152
],
"switchSubscriptionInterval": [
154
],
@@ -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<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 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<CreateBillingPaymentMethodSetupIntentMutation, CreateBillingPaymentMethodSetupIntentMutationVariables>;
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>;
@@ -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 && (
<StartSubscriptionConfirmationModal
modalInstanceId={AI_CHAT_END_TRIAL_PERIOD_MODAL_ID}
hasPaymentMethod={hasPaymentMethod}
onConfirmClick={endTrialPeriodFromAiChat}
loading={isEndTrialLoading}
/>
)}
{isTrialing &&
(hasPaymentMethod === false ? (
<AddCreditCardModal
modalInstanceId={AI_CHAT_END_TRIAL_PERIOD_MODAL_ID}
finalRedirectPath={finalRedirectPath}
onPaymentMethodAdded={startSubscriptionAfterPaymentMethodFromAiChat}
/>
) : (
<StartSubscriptionConfirmationModal
modalInstanceId={AI_CHAT_END_TRIAL_PERIOD_MODAL_ID}
hasPaymentMethod={hasPaymentMethod}
onConfirmClick={endTrialPeriodFromAiChat}
loading={isEndTrialLoading}
/>
))}
{!isTrialing && (
<ConfirmationModal
modalInstanceId={AI_CHAT_UPGRADE_CREDIT_PLAN_MODAL_ID}
@@ -1,31 +1,46 @@
import { currentAiChatThreadState } from '@/ai/states/currentAiChatThreadState';
import { useOpenAskAiThread } from '@/ai/hooks/useOpenAskAiThread';
import { buildAskAiThreadRedirectPath } from '@/ai/utils/buildAskAiThreadRedirectPath';
import { billingHasPaymentMethodSelector } from '@/settings/billing/states/billingHasPaymentMethodSelector';
import { useEndSubscriptionTrialPeriod } from '@/settings/billing/hooks/useEndSubscriptionTrialPeriod';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { isNonEmptyString } from '@sniptt/guards';
import { useLocation } from 'react-router-dom';
export const useAiChatEndTrialPeriod = () => {
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,
};
@@ -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 && (
<StartSubscriptionConfirmationModal
modalInstanceId={INFORMATION_BANNER_END_TRIAL_PERIOD_MODAL_ID}
hasPaymentMethod={billingHasPaymentMethod}
onConfirmClick={async () => {
await endTrialPeriod();
}}
loading={isLoading}
/>
)}
{hasPermissionToEndTrialPeriod &&
(billingHasPaymentMethod === false ? (
<AddCreditCardModal
modalInstanceId={INFORMATION_BANNER_END_TRIAL_PERIOD_MODAL_ID}
onPaymentMethodAdded={async () => {
await endTrialPeriod({ skipPaymentMethodRedirect: true });
}}
/>
) : (
<StartSubscriptionConfirmationModal
modalInstanceId={INFORMATION_BANNER_END_TRIAL_PERIOD_MODAL_ID}
hasPaymentMethod={billingHasPaymentMethod}
onConfirmClick={async () => {
await endTrialPeriod();
}}
loading={isLoading}
/>
))}
</>
);
};
@@ -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<void>;
};
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 (
<ModalStatefulWrapper
modalInstanceId={modalInstanceId}
isClosable={true}
padding="large"
overlay="dark"
dataGloballyPreventClickOutside
renderInDocumentBody
smallBorderRadius
narrowWidth
autoHeight
>
<StyledCenteredTitle>
<H1Title
title={t`Add your credit card`}
fontColor={H1TitleFontColor.Primary}
/>
</StyledCenteredTitle>
<StyledSectionContainer>
<Section
alignment={SectionAlignment.Center}
fontColor={SectionFontColor.Primary}
>
{t`Add your credit card below. Once added, your subscription will start automatically.`}
</Section>
</StyledSectionContainer>
<AddPaymentMethodForm
finalRedirectPath={finalRedirectPath}
onPaymentMethodAdded={handlePaymentMethodAdded}
/>
<StyledCancelButtonContainer>
<Button
onClick={() => closeModal(modalInstanceId)}
variant="secondary"
title={t`Cancel`}
fullWidth
justify="center"
/>
</StyledCancelButtonContainer>
</ModalStatefulWrapper>
);
};
@@ -0,0 +1,189 @@
import { currentUserState } from '@/auth/states/currentUserState';
import { START_SUBSCRIPTION_AFTER_PAYMENT_METHOD_QUERY_PARAM } from '@/settings/billing/constants/StartSubscriptionAfterPaymentMethodQueryParam';
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 { useLocation } from 'react-router-dom';
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 { CreateBillingPaymentMethodSetupIntentDocument } from '~/generated-metadata/graphql';
type AddPaymentMethodFormContentProps = {
finalRedirectPath?: string;
onPaymentMethodAdded: () => Promise<void>;
};
type AddPaymentMethodFormProps = AddPaymentMethodFormContentProps;
const StyledFormContainer = styled.div`
display: flex;
flex-direction: column;
gap: ${themeCssVariables.spacing[4]};
width: 100%;
`;
const StyledButtonContainer = styled.div`
display: flex;
justify-content: center;
`;
const AddPaymentMethodFormContent = ({
finalRedirectPath,
onPaymentMethodAdded,
}: AddPaymentMethodFormContentProps) => {
const stripe = useStripe();
const elements = useElements();
const { enqueueErrorSnackBar } = useSnackBar();
const [isSubmitting, setIsSubmitting] = useState(false);
const location = useLocation();
const customerEmail = useAtomStateValue(currentUserState)?.email;
const [createBillingPaymentMethodSetupIntent] = useMutation(
CreateBillingPaymentMethodSetupIntentDocument,
);
const isStripeReady = isDefined(stripe) && isDefined(elements);
const buildReturnUrl = () => {
const basePath =
finalRedirectPath ?? `${location.pathname}${location.search}`;
const returnUrl = new URL(basePath, window.location.origin);
returnUrl.searchParams.set(
START_SUBSCRIPTION_AFTER_PAYMENT_METHOD_QUERY_PARAM,
'true',
);
return returnUrl.toString();
};
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 { data } = await createBillingPaymentMethodSetupIntent();
const clientSecret =
data?.createBillingPaymentMethodSetupIntent?.clientSecret;
if (!isDefined(clientSecret)) {
enqueueErrorSnackBar({
message: t`Subscription error. Please retry or contact Twenty team`,
});
setIsSubmitting(false);
return;
}
const { error, setupIntent } = await stripe.confirmSetup({
elements,
clientSecret,
confirmParams: { return_url: buildReturnUrl() },
redirect: 'if_required',
});
if (isDefined(error)) {
enqueueErrorSnackBar({
message:
error.message ??
t`We couldn't confirm your payment method. Please retry.`,
});
setIsSubmitting(false);
return;
}
if (setupIntent?.status === 'succeeded') {
await onPaymentMethodAdded();
}
} 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`Add credit card`}
onClick={handleSubmit}
width={200}
Icon={() => (isSubmitting ? <Loader /> : null)}
disabled={!isStripeReady || isSubmitting}
/>
</StyledButtonContainer>
</StyledFormContainer>
);
};
export const AddPaymentMethodForm = ({
finalRedirectPath,
onPaymentMethodAdded,
}: AddPaymentMethodFormProps) => {
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: 'setup', currency: 'usd', appearance }}
>
<AddPaymentMethodFormContent
finalRedirectPath={finalRedirectPath}
onPaymentMethodAdded={onPaymentMethodAdded}
/>
</Elements>
);
};
@@ -0,0 +1,9 @@
import { gql } from '@apollo/client';
export const CREATE_BILLING_PAYMENT_METHOD_SETUP_INTENT = gql`
mutation CreateBillingPaymentMethodSetupIntent {
createBillingPaymentMethodSetupIntent {
clientSecret
}
}
`;
@@ -178,6 +178,19 @@ export class BillingResolver {
});
}
@Mutation(() => BillingPaymentIntentDTO)
@UseGuards(
WorkspaceAuthGuard,
SettingsPermissionGuard(PermissionFlagType.BILLING),
)
async createBillingPaymentMethodSetupIntent(
@AuthWorkspace() workspace: WorkspaceEntity,
): Promise<BillingPaymentIntentDTO> {
return this.billingPortalWorkspaceService.createPaymentMethodSetupIntent(
workspace,
);
}
@Mutation(() => BillingUpdateDTO)
@UseGuards(
WorkspaceAuthGuard,
@@ -24,6 +24,7 @@ import { SubscriptionStatus } from 'src/engine/core-modules/billing/enums/billin
import { BillingSubscriptionService } from 'src/engine/core-modules/billing/services/billing-subscription.service';
import { StripeBillingPortalService } from 'src/engine/core-modules/billing/stripe/services/stripe-billing-portal.service';
import { StripeCheckoutService } from 'src/engine/core-modules/billing/stripe/services/stripe-checkout.service';
import { StripeCustomerService } from 'src/engine/core-modules/billing/stripe/services/stripe-customer.service';
import { type BillingGetPricesPerPlanResult } from 'src/engine/core-modules/billing/types/billing-get-prices-per-plan-result.type';
import { type BillingPortalCheckoutSessionParameters } from 'src/engine/core-modules/billing/types/billing-portal-checkout-session-parameters.type';
import { WorkspaceDomainsService } from 'src/engine/core-modules/domain/workspace-domains/services/workspace-domains.service';
@@ -36,6 +37,7 @@ export class BillingPortalWorkspaceService {
protected readonly logger = new Logger(BillingPortalWorkspaceService.name);
constructor(
private readonly stripeCheckoutService: StripeCheckoutService,
private readonly stripeCustomerService: StripeCustomerService,
private readonly stripeBillingPortalService: StripeBillingPortalService,
private readonly workspaceDomainsService: WorkspaceDomainsService,
private readonly billingSubscriptionService: BillingSubscriptionService,
@@ -181,6 +183,43 @@ export class BillingPortalWorkspaceService {
return paymentIntent;
}
async createPaymentMethodSetupIntent(
workspace: WorkspaceEntity,
): Promise<{ clientSecret: string; paymentIntentType: string }> {
const subscription = await this.billingSubscriptionRepository.findOne(
workspace.id,
{
where: { status: Not(SubscriptionStatus.Canceled) },
order: { createdAt: 'DESC' },
},
);
const stripeCustomerId = subscription?.stripeCustomerId;
if (!isDefined(stripeCustomerId)) {
throw new BillingException(
'Error: missing subscription for payment method setup intent',
BillingExceptionCode.BILLING_SUBSCRIPTION_NOT_FOUND,
);
}
const setupIntent =
await this.stripeCustomerService.createSetupIntent(stripeCustomerId);
assertIsDefinedOrThrow(
setupIntent.client_secret,
new BillingException(
'Error: missing setupIntent.client_secret',
BillingExceptionCode.BILLING_STRIPE_ERROR,
),
);
return {
clientSecret: setupIntent.client_secret,
paymentIntentType: 'setup',
};
}
// 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.
@@ -268,6 +268,10 @@ export class BillingSubscriptionService {
};
}
await this.stripeCustomerService.ensureDefaultPaymentMethod(
billingSubscription.stripeCustomerId,
);
const updatedSubscription =
await this.stripeSubscriptionService.updateSubscription(
billingSubscription.stripeSubscriptionId,
@@ -2,6 +2,7 @@
import { Injectable, Logger } from '@nestjs/common';
import { isDefined } from 'twenty-shared/utils';
import type Stripe from 'stripe';
import { BillingCustomerEntity } from 'src/engine/core-modules/billing/entities/billing-customer.entity';
@@ -44,6 +45,42 @@ export class StripeCustomerService {
return paymentMethods.length > 0;
}
async createSetupIntent(
stripeCustomerId: string,
): Promise<Stripe.SetupIntent> {
return await this.stripe.setupIntents.create({
customer: stripeCustomerId,
usage: 'off_session',
automatic_payment_methods: { enabled: true },
});
}
async ensureDefaultPaymentMethod(stripeCustomerId: string): Promise<void> {
const customer = await this.stripe.customers.retrieve(stripeCustomerId);
if ('deleted' in customer && customer.deleted === true) {
return;
}
if (isDefined(customer.invoice_settings?.default_payment_method)) {
return;
}
const { data: paymentMethods } =
await this.stripe.customers.listPaymentMethods(stripeCustomerId, {
limit: 1,
});
const paymentMethodId = paymentMethods[0]?.id;
if (!isDefined(paymentMethodId)) {
return;
}
await this.stripe.customers.update(stripeCustomerId, {
invoice_settings: { default_payment_method: paymentMethodId },
});
}
async createStripeCustomer(
userEmail: string,
workspaceId: string,