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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user