feat(billing): embed Stripe Payment Element in onboarding (#21759)

## What & why

Replaces the hosted Stripe Checkout redirect on the onboarding "Choose
your plan" step (credit-card trial) with an inline Stripe **Payment
Element**, so users never leave the app to enter card details.

## How it works

- **Frontend:** a deferred `<Elements mode="setup">` renders the Payment
Element, themed via the Appearance API. On Continue: `elements.submit()`
→ `checkoutSession` mutation creates the trialing subscription
server-side and returns its pending SetupIntent `clientSecret` →
`stripe.confirmSetup()` confirms the card (handling 3DS) → redirect to
the existing `/plan-required/payment-success`.
- **Backend:** new `BILLING_STRIPE_PUBLISHABLE_KEY` config var exposed
via `/client-config`; the card path creates the subscription with
`payment_behavior: default_incomplete` + a free trial (so Stripe
attaches a `pending_setup_intent`) and returns its client secret. The
hosted-Checkout code path is removed.
- The **no-credit-card** trial path is unchanged.
- Billing address collection is **disabled** in the Payment Element to
reduce friction; `automatic_tax` is correspondingly disabled (tax needs
an address — collect it later, e.g. at conversion / via the billing
portal).

## Required before this works
1. Set `BILLING_STRIPE_PUBLISHABLE_KEY` (`pk_…`) on the server (infra
change pending).
2. Run `nx run twenty-front:graphql:generate --configuration=metadata`
against a server exposing the updated schema (see inline note on the
hand-authored document).
3. Verify in Stripe test mode: happy path, 3DS (`4000 0025 0000 3155`),
a decline.

## Verified
typecheck (front + server), oxlint + oxfmt clean,
`client-config.service.spec` passing. Not run here: the app end-to-end /
Stripe test mode and `graphql:generate` (no server/DB in the dev
container).

I've left self-review comments inline flagging cleanup opportunities
plus a couple of architectural/tech-debt items.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_01TxCfinXq7abSrbF7aTw2cA

---
_Generated by [Claude
Code](https://claude.ai/code/session_01TxCfinXq7abSrbF7aTw2cA)_

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/21759?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->

---------

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Félix Malfait
2026-06-19 11:40:55 +02:00
committed by GitHub
parent 1e744a761d
commit adf6eb572b
21 changed files with 1253 additions and 669 deletions
@@ -12,6 +12,7 @@ import { type AuthContextUser } from 'src/engine/core-modules/auth/types/auth-co
import { BillingEndTrialPeriodDTO } from 'src/engine/core-modules/billing/dtos/billing-end-trial-period.dto';
import { BillingResourceCreditUsageDTO } from 'src/engine/core-modules/billing/dtos/billing-resource-credit-usage.dto';
import { BillingPlanDTO } from 'src/engine/core-modules/billing/dtos/billing-plan.dto';
import { BillingPaymentIntentDTO } from 'src/engine/core-modules/billing/dtos/billing-payment-intent.dto';
import { BillingSessionDTO } from 'src/engine/core-modules/billing/dtos/billing-session.dto';
import { BillingUpdateDTO } from 'src/engine/core-modules/billing/dtos/billing-update.dto';
import { BillingCheckoutSessionInput } from 'src/engine/core-modules/billing/dtos/inputs/billing-checkout-session.input';
@@ -143,6 +144,40 @@ export class BillingResolver {
}
}
@Mutation(() => BillingPaymentIntentDTO)
@UseGuards(WorkspaceAuthGuard, UserAuthGuard, NoPermissionGuard)
async createSubscriptionPaymentIntent(
@AuthWorkspace() workspace: WorkspaceEntity,
@AuthUser() user: AuthContextUser,
@AuthUserWorkspaceId() userWorkspaceId: string,
@Args() { recurringInterval, plan }: BillingCheckoutSessionInput,
@Args('idempotencyKey', { type: () => String }) idempotencyKey: string,
@AuthApiKey() apiKey?: ApiKeyEntity,
): Promise<BillingPaymentIntentDTO> {
await this.validateCanCheckoutSessionPermissionOrThrow({
workspaceId: workspace.id,
userWorkspaceId,
apiKeyId: apiKey?.id,
workspaceActivationStatus: workspace.activationStatus,
});
const resolvedPlan = plan ?? BillingPlanKey.PRO;
const billingPricesPerPlan =
await this.billingPlanService.getPricesPerPlanByInterval({
planKey: resolvedPlan,
interval: recurringInterval,
});
return this.billingPortalWorkspaceService.createSubscriptionPaymentIntent({
user,
workspace,
plan: resolvedPlan,
billingPricesPerPlan,
idempotencyKey,
});
}
@Mutation(() => BillingUpdateDTO)
@UseGuards(
WorkspaceAuthGuard,
@@ -0,0 +1,12 @@
/* @license Enterprise */
import { Field, ObjectType } from '@nestjs/graphql';
@ObjectType('BillingPaymentIntent')
export class BillingPaymentIntentDTO {
@Field(() => String)
clientSecret: string;
@Field(() => String)
paymentIntentType: string;
}
@@ -72,8 +72,7 @@ export class BillingPortalWorkspaceService {
stripeCustomerId: customer?.stripeCustomerId,
plan,
requirePaymentMethod,
withTrialPeriod:
!isDefined(customer) || customer.billingSubscriptions.length === 0,
withTrialPeriod: this.isCustomerEligibleForTrialPeriod(customer),
});
assertIsDefinedOrThrow(
@@ -122,8 +121,7 @@ export class BillingPortalWorkspaceService {
stripeCustomerId: customer?.stripeCustomerId,
plan,
requirePaymentMethod,
withTrialPeriod:
!isDefined(customer) || customer.billingSubscriptions.length === 0,
withTrialPeriod: this.isCustomerEligibleForTrialPeriod(customer),
});
await this.billingSubscriptionService.syncSubscriptionToDatabase(
@@ -134,6 +132,158 @@ export class BillingPortalWorkspaceService {
return successUrl;
}
async createSubscriptionPaymentIntent({
user,
workspace,
billingPricesPerPlan,
plan,
idempotencyKey,
}: BillingPortalCheckoutSessionParameters & {
idempotencyKey: string;
}): Promise<{
clientSecret: string;
paymentIntentType: string;
}> {
const { customer, stripeSubscriptionLineItems } =
await this.prepareSubscriptionParameters({
workspace,
billingPricesPerPlan,
});
const resumablePaymentIntent =
await this.findResumableSubscriptionPaymentIntent(customer);
if (isDefined(resumablePaymentIntent)) {
return resumablePaymentIntent;
}
const stripeSubscription =
await this.stripeCheckoutService.createSubscriptionWithPaymentMethodCollection(
{
user,
workspace,
stripeSubscriptionLineItems,
stripeCustomerId: customer?.stripeCustomerId,
plan,
withTrialPeriod: this.isCustomerEligibleForTrialPeriod(customer),
idempotencyKey,
},
);
await this.billingSubscriptionService.syncSubscriptionToDatabase(
workspace.id,
stripeSubscription.id,
);
const paymentIntent =
this.extractSubscriptionClientSecret(stripeSubscription);
return paymentIntent;
}
// A failed earlier attempt leaves an incomplete subscription; it must not
// count, or a retry would be charged immediately instead of getting the
// trial. Only a real (non-incomplete) subscription blocks a new trial.
private isCustomerEligibleForTrialPeriod(
customer: BillingCustomerEntity | null,
): boolean {
return (
!isDefined(customer) ||
!customer.billingSubscriptions.some(
(subscription) =>
subscription.status !== SubscriptionStatus.Incomplete &&
subscription.status !== SubscriptionStatus.IncompleteExpired,
)
);
}
private async findResumableSubscriptionPaymentIntent(
customer: BillingCustomerEntity | null,
): Promise<{ clientSecret: string; paymentIntentType: string } | null> {
const existingSubscription = customer?.billingSubscriptions?.find(
(subscription) => subscription.status !== SubscriptionStatus.Canceled,
);
if (!isDefined(existingSubscription)) {
return null;
}
const stripeSubscription =
await this.stripeCheckoutService.retrieveSubscriptionForResume(
existingSubscription.stripeSubscriptionId,
);
const paymentIntent = this.findSubscriptionClientSecret(stripeSubscription);
if (isDefined(paymentIntent)) {
return paymentIntent;
}
if (
stripeSubscription.status === 'incomplete' ||
stripeSubscription.status === 'incomplete_expired'
) {
return null;
}
throw new BillingException(
'Customer already has a non-canceled billing subscription',
BillingExceptionCode.BILLING_SUBSCRIPTION_INVALID,
);
}
private extractSubscriptionClientSecret(subscription: Stripe.Subscription): {
clientSecret: string;
paymentIntentType: string;
} {
const paymentIntent = this.findSubscriptionClientSecret(subscription);
if (!isDefined(paymentIntent)) {
throw new BillingException(
'Error: missing subscription client secret',
BillingExceptionCode.BILLING_STRIPE_ERROR,
);
}
return paymentIntent;
}
private findSubscriptionClientSecret(subscription: Stripe.Subscription): {
clientSecret: string;
paymentIntentType: string;
} | null {
const pendingSetupIntent = subscription.pending_setup_intent;
if (
isDefined(pendingSetupIntent) &&
typeof pendingSetupIntent !== 'string' &&
isDefined(pendingSetupIntent.client_secret)
) {
return {
clientSecret: pendingSetupIntent.client_secret,
paymentIntentType: 'setup',
};
}
const latestInvoice = subscription.latest_invoice;
const confirmationSecret =
isDefined(latestInvoice) && typeof latestInvoice !== 'string'
? latestInvoice.confirmation_secret
: undefined;
if (
isDefined(confirmationSecret) &&
isDefined(confirmationSecret.client_secret)
) {
return {
clientSecret: confirmationSecret.client_secret,
paymentIntentType: 'payment',
};
}
return null;
}
private async prepareSubscriptionParameters({
workspace,
billingPricesPerPlan,
@@ -52,16 +52,11 @@ export class StripeCheckoutService {
requirePaymentMethod?: boolean;
withTrialPeriod: boolean;
}): Promise<Stripe.Checkout.Session> {
if (!isDefined(stripeCustomerId)) {
const stripeCustomer =
await this.stripeCustomerService.createStripeCustomer(
user.email,
workspace.id,
workspace.displayName,
);
stripeCustomerId = stripeCustomer.id;
}
stripeCustomerId = await this.getOrCreateStripeCustomerId({
user,
workspace,
stripeCustomerId,
});
return await this.stripe.checkout.sessions.create({
line_items: stripeSubscriptionLineItems,
@@ -105,18 +100,12 @@ export class StripeCheckoutService {
requirePaymentMethod?: boolean;
withTrialPeriod: boolean;
}): Promise<Stripe.Subscription> {
if (!isDefined(stripeCustomerId)) {
const stripeCustomer =
await this.stripeCustomerService.createStripeCustomer(
user.email,
workspace.id,
workspace.displayName,
);
stripeCustomerId = await this.getOrCreateStripeCustomerId({
user,
workspace,
stripeCustomerId,
});
stripeCustomerId = stripeCustomer.id;
}
// Convert checkout session line items to subscription items format
const subscriptionItems: Stripe.SubscriptionCreateParams.Item[] =
stripeSubscriptionLineItems.map((lineItem) => ({
price: lineItem.price as string,
@@ -140,6 +129,88 @@ export class StripeCheckoutService {
return await this.stripe.subscriptions.create(subscriptionParams);
}
async createSubscriptionWithPaymentMethodCollection({
user,
workspace,
stripeSubscriptionLineItems,
stripeCustomerId,
plan = BillingPlanKey.PRO,
withTrialPeriod,
idempotencyKey,
}: {
user: AuthContextUser;
workspace: Pick<WorkspaceEntity, 'id' | 'displayName'>;
stripeSubscriptionLineItems: Stripe.Checkout.SessionCreateParams.LineItem[];
stripeCustomerId?: string;
plan?: BillingPlanKey;
withTrialPeriod: boolean;
idempotencyKey: string;
}): Promise<Stripe.Subscription> {
const customerId = await this.getOrCreateStripeCustomerId({
user,
workspace,
stripeCustomerId,
});
const subscriptionItems: Stripe.SubscriptionCreateParams.Item[] =
stripeSubscriptionLineItems.map((lineItem) => ({
price: lineItem.price as string,
quantity: lineItem.quantity,
}));
return await this.stripe.subscriptions.create(
{
customer: customerId,
items: subscriptionItems,
metadata: {
workspaceId: workspace.id,
plan,
},
payment_behavior: 'default_incomplete',
payment_settings: {
save_default_payment_method: 'on_subscription',
},
...this.getStripeSubscriptionTrialPeriodConfig(withTrialPeriod, true),
automatic_tax: { enabled: false },
expand: ['pending_setup_intent', 'latest_invoice.confirmation_secret'],
},
{
idempotencyKey: `onboarding-subscription-${workspace.id}-${idempotencyKey}`,
},
);
}
async retrieveSubscriptionForResume(
stripeSubscriptionId: string,
): Promise<Stripe.Subscription> {
return this.stripe.subscriptions.retrieve(stripeSubscriptionId, {
expand: ['pending_setup_intent', 'latest_invoice.confirmation_secret'],
});
}
private async getOrCreateStripeCustomerId({
user,
workspace,
stripeCustomerId,
}: {
user: AuthContextUser;
workspace: Pick<WorkspaceEntity, 'id' | 'displayName'>;
stripeCustomerId?: string;
}): Promise<string> {
if (isDefined(stripeCustomerId)) {
return stripeCustomerId;
}
const stripeCustomer =
await this.stripeCustomerService.createStripeCustomer(
user.email,
workspace.id,
workspace.displayName,
);
return stripeCustomer.id;
}
private getStripeSubscriptionTrialPeriodConfig(
withTrialPeriod: boolean,
requirePaymentMethod: boolean,
@@ -165,6 +165,9 @@ export class Billing {
@Field(() => String, { nullable: true })
billingUrl?: string;
@Field(() => String, { nullable: true })
stripePublishableKey?: string;
@Field(() => [BillingTrialPeriodDTO])
trialPeriods: BillingTrialPeriodDTO[];
}
@@ -74,6 +74,7 @@ describe('ClientConfigService', () => {
BILLING_PLAN_REQUIRED_LINK: 'https://billing.example.com',
BILLING_FREE_TRIAL_WITH_CREDIT_CARD_DURATION_IN_DAYS: 30,
BILLING_FREE_TRIAL_WITHOUT_CREDIT_CARD_DURATION_IN_DAYS: 7,
BILLING_STRIPE_PUBLISHABLE_KEY: 'pk_test_123',
AUTH_GOOGLE_ENABLED: true,
AUTH_PASSWORD_ENABLED: true,
AUTH_MICROSOFT_ENABLED: false,
@@ -121,6 +122,7 @@ describe('ClientConfigService', () => {
billing: {
isBillingEnabled: true,
billingUrl: 'https://billing.example.com',
stripePublishableKey: 'pk_test_123',
trialPeriods: [
{
duration: 30,
@@ -160,6 +160,9 @@ export class ClientConfigService {
billing: {
isBillingEnabled: this.twentyConfigService.get('IS_BILLING_ENABLED'),
billingUrl: this.twentyConfigService.get('BILLING_PLAN_REQUIRED_LINK'),
stripePublishableKey: this.twentyConfigService.get(
'BILLING_STRIPE_PUBLISHABLE_KEY',
),
trialPeriods: [
{
duration: this.twentyConfigService.get(
@@ -836,6 +836,15 @@ export class ConfigVariables {
@ValidateIf((env) => env.IS_BILLING_ENABLED === true)
BILLING_STRIPE_WEBHOOK_SECRET: string;
@ConfigVariablesMetadata({
group: ConfigVariablesGroup.BILLING_CONFIG,
description:
'Stripe publishable key for billing, exposed to the frontend to mount Stripe Elements',
type: ConfigVariableType.STRING,
})
@ValidateIf((env) => env.IS_BILLING_ENABLED === true)
BILLING_STRIPE_PUBLISHABLE_KEY: string;
@ConfigVariablesMetadata({
group: ConfigVariablesGroup.BILLING_CONFIG,
description: