Billing - fix duplicate customer in stripe + subscription constraint violation (#13091)

closes https://github.com/twentyhq/core-team-issues/issues/982
This commit is contained in:
Etienne
2025-07-08 11:17:59 +02:00
committed by GitHub
parent 67f0b98002
commit 56607c0449
7 changed files with 25 additions and 6 deletions

View File

@ -210,7 +210,7 @@ const testCases: {
{ loc: AppPath.PlanRequiredSuccess, isLoggedIn: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PLAN_REQUIRED, res: undefined }, { loc: AppPath.PlanRequiredSuccess, isLoggedIn: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PLAN_REQUIRED, res: undefined },
{ loc: AppPath.PlanRequiredSuccess, isLoggedIn: true, isWorkspaceSuspended: true, onboardingStatus: OnboardingStatus.COMPLETED, res: '/settings/billing' }, { loc: AppPath.PlanRequiredSuccess, isLoggedIn: true, isWorkspaceSuspended: true, onboardingStatus: OnboardingStatus.COMPLETED, res: '/settings/billing' },
{ loc: AppPath.PlanRequiredSuccess, isLoggedIn: false, isWorkspaceSuspended: false, onboardingStatus: undefined, res: AppPath.SignInUp }, { loc: AppPath.PlanRequiredSuccess, isLoggedIn: false, isWorkspaceSuspended: false, onboardingStatus: undefined, res: AppPath.SignInUp },
{ loc: AppPath.PlanRequiredSuccess, isLoggedIn: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.WORKSPACE_ACTIVATION, res: undefined }, { loc: AppPath.PlanRequiredSuccess, isLoggedIn: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.WORKSPACE_ACTIVATION, res: AppPath.CreateWorkspace },
{ loc: AppPath.PlanRequiredSuccess, isLoggedIn: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PROFILE_CREATION, res: AppPath.CreateProfile }, { loc: AppPath.PlanRequiredSuccess, isLoggedIn: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PROFILE_CREATION, res: AppPath.CreateProfile },
{ loc: AppPath.PlanRequiredSuccess, isLoggedIn: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.SYNC_EMAIL, res: AppPath.SyncEmails }, { loc: AppPath.PlanRequiredSuccess, isLoggedIn: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.SYNC_EMAIL, res: AppPath.SyncEmails },
{ loc: AppPath.PlanRequiredSuccess, isLoggedIn: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.INVITE_TEAM, res: AppPath.InviteTeam }, { loc: AppPath.PlanRequiredSuccess, isLoggedIn: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.INVITE_TEAM, res: AppPath.InviteTeam },

View File

@ -92,7 +92,6 @@ export const usePageChangeEffectNavigateLocation = () => {
onboardingStatus === OnboardingStatus.WORKSPACE_ACTIVATION && onboardingStatus === OnboardingStatus.WORKSPACE_ACTIVATION &&
!someMatchingLocationOf([ !someMatchingLocationOf([
AppPath.CreateWorkspace, AppPath.CreateWorkspace,
AppPath.PlanRequiredSuccess,
AppPath.BookCallDecision, AppPath.BookCallDecision,
AppPath.BookCall, AppPath.BookCall,
]) ])

View File

@ -1,5 +1,4 @@
import { useRedirect } from '@/domain-manager/hooks/useRedirect'; import { useRedirect } from '@/domain-manager/hooks/useRedirect';
import { SettingsPath } from '@/types/SettingsPath';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { t } from '@lingui/core/macro'; import { t } from '@lingui/core/macro';
import { useState } from 'react'; import { useState } from 'react';
@ -8,16 +7,17 @@ import {
SubscriptionInterval, SubscriptionInterval,
useCheckoutSessionMutation, useCheckoutSessionMutation,
} from '~/generated-metadata/graphql'; } from '~/generated-metadata/graphql';
import { getSettingsPath } from '~/utils/navigation/getSettingsPath';
export const useHandleCheckoutSession = ({ export const useHandleCheckoutSession = ({
recurringInterval, recurringInterval,
plan, plan,
requirePaymentMethod, requirePaymentMethod,
successUrlPath,
}: { }: {
recurringInterval: SubscriptionInterval; recurringInterval: SubscriptionInterval;
plan: BillingPlanKey; plan: BillingPlanKey;
requirePaymentMethod: boolean; requirePaymentMethod: boolean;
successUrlPath: string;
}) => { }) => {
const { redirect } = useRedirect(); const { redirect } = useRedirect();
@ -32,7 +32,7 @@ export const useHandleCheckoutSession = ({
const { data } = await checkoutSession({ const { data } = await checkoutSession({
variables: { variables: {
recurringInterval, recurringInterval,
successUrlPath: getSettingsPath(SettingsPath.Billing), successUrlPath,
plan, plan,
requirePaymentMethod, requirePaymentMethod,
}, },

View File

@ -2,14 +2,17 @@ import { BILLING_CHECKOUT_SESSION_DEFAULT_VALUE } from '@/billing/constants/Bill
import { useHandleCheckoutSession } from '@/billing/hooks/useHandleCheckoutSession'; import { useHandleCheckoutSession } from '@/billing/hooks/useHandleCheckoutSession';
import { InformationBanner } from '@/information-banner/components/InformationBanner'; import { InformationBanner } from '@/information-banner/components/InformationBanner';
import { useSettingsPermissionMap } from '@/settings/roles/hooks/useSettingsPermissionMap'; import { useSettingsPermissionMap } from '@/settings/roles/hooks/useSettingsPermissionMap';
import { SettingsPath } from '@/types/SettingsPath';
import { t } from '@lingui/core/macro'; import { t } from '@lingui/core/macro';
import { SettingPermissionType } from '~/generated-metadata/graphql'; import { SettingPermissionType } from '~/generated-metadata/graphql';
import { getSettingsPath } from '~/utils/navigation/getSettingsPath';
export const InformationBannerNoBillingSubscription = () => { export const InformationBannerNoBillingSubscription = () => {
const { handleCheckoutSession, isSubmitting } = useHandleCheckoutSession({ const { handleCheckoutSession, isSubmitting } = useHandleCheckoutSession({
recurringInterval: BILLING_CHECKOUT_SESSION_DEFAULT_VALUE.interval, recurringInterval: BILLING_CHECKOUT_SESSION_DEFAULT_VALUE.interval,
plan: BILLING_CHECKOUT_SESSION_DEFAULT_VALUE.plan, plan: BILLING_CHECKOUT_SESSION_DEFAULT_VALUE.plan,
requirePaymentMethod: true, requirePaymentMethod: true,
successUrlPath: getSettingsPath(SettingsPath.Billing),
}); });
const { [SettingPermissionType.WORKSPACE]: hasPermissionToSubscribe } = const { [SettingPermissionType.WORKSPACE]: hasPermissionToSubscribe } =

View File

@ -158,6 +158,7 @@ export const ChooseYourPlan = () => {
recurringInterval: billingCheckoutSession.interval, recurringInterval: billingCheckoutSession.interval,
plan: billingCheckoutSession.plan, plan: billingCheckoutSession.plan,
requirePaymentMethod: billingCheckoutSession.requirePaymentMethod, requirePaymentMethod: billingCheckoutSession.requirePaymentMethod,
successUrlPath: AppPath.PlanRequiredSuccess,
}); });
const handleTrialPeriodChange = (withCreditCard: boolean) => { const handleTrialPeriodChange = (withCreditCard: boolean) => {

View File

@ -1,10 +1,13 @@
/* @license Enterprise */ /* @license Enterprise */
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import Stripe from 'stripe'; import Stripe from 'stripe';
import { isDefined } from 'twenty-shared/utils'; import { isDefined } from 'twenty-shared/utils';
import { Repository } from 'typeorm';
import { BillingCustomer } from 'src/engine/core-modules/billing/entities/billing-customer.entity';
import { BillingPlanKey } from 'src/engine/core-modules/billing/enums/billing-plan-key.enum'; import { BillingPlanKey } from 'src/engine/core-modules/billing/enums/billing-plan-key.enum';
import { StripeSDKService } from 'src/engine/core-modules/billing/stripe/stripe-sdk/services/stripe-sdk.service'; import { StripeSDKService } from 'src/engine/core-modules/billing/stripe/stripe-sdk/services/stripe-sdk.service';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
@ -18,6 +21,8 @@ export class StripeCheckoutService {
constructor( constructor(
private readonly twentyConfigService: TwentyConfigService, private readonly twentyConfigService: TwentyConfigService,
private readonly stripeSDKService: StripeSDKService, private readonly stripeSDKService: StripeSDKService,
@InjectRepository(BillingCustomer, 'core')
private readonly billingCustomerRepository: Repository<BillingCustomer>,
) { ) {
if (!this.twentyConfigService.get('IS_BILLING_ENABLED')) { if (!this.twentyConfigService.get('IS_BILLING_ENABLED')) {
return; return;
@ -56,6 +61,11 @@ export class StripeCheckoutService {
}, },
}); });
await this.billingCustomerRepository.save({
stripeCustomerId: customer.id,
workspaceId,
});
stripeCustomerId = customer.id; stripeCustomerId = customer.id;
} }

View File

@ -1,7 +1,9 @@
/* @license Enterprise */ /* @license Enterprise */
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { BillingCustomer } from 'src/engine/core-modules/billing/entities/billing-customer.entity';
import { StripeBillingMeterEventService } from 'src/engine/core-modules/billing/stripe/services/stripe-billing-meter-event.service'; import { StripeBillingMeterEventService } from 'src/engine/core-modules/billing/stripe/services/stripe-billing-meter-event.service';
import { StripeBillingMeterService } from 'src/engine/core-modules/billing/stripe/services/stripe-billing-meter.service'; import { StripeBillingMeterService } from 'src/engine/core-modules/billing/stripe/services/stripe-billing-meter.service';
import { StripeBillingPortalService } from 'src/engine/core-modules/billing/stripe/services/stripe-billing-portal.service'; import { StripeBillingPortalService } from 'src/engine/core-modules/billing/stripe/services/stripe-billing-portal.service';
@ -16,7 +18,11 @@ import { StripeSDKModule } from 'src/engine/core-modules/billing/stripe/stripe-s
import { DomainManagerModule } from 'src/engine/core-modules/domain-manager/domain-manager.module'; import { DomainManagerModule } from 'src/engine/core-modules/domain-manager/domain-manager.module';
@Module({ @Module({
imports: [DomainManagerModule, StripeSDKModule], imports: [
DomainManagerModule,
StripeSDKModule,
TypeOrmModule.forFeature([BillingCustomer], 'core'),
],
providers: [ providers: [
StripeSubscriptionItemService, StripeSubscriptionItemService,
StripeWebhookService, StripeWebhookService,