diff --git a/packages/twenty-server/src/engine/core-modules/auth/services/sign-in-up.service.spec.ts b/packages/twenty-server/src/engine/core-modules/auth/services/sign-in-up.service.spec.ts index 658e67d1bc..6f6019632f 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/services/sign-in-up.service.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/services/sign-in-up.service.spec.ts @@ -108,6 +108,9 @@ const createSignInUpServiceForTests = () => { { creditWorkspaceBalance: jest.fn(), } as any, + { + isBillingEnabled: jest.fn(), + } as any, { createQueryRunner: jest.fn(() => queryRunnerMock), } as any, diff --git a/packages/twenty-server/src/engine/core-modules/auth/services/sign-in-up.service.ts b/packages/twenty-server/src/engine/core-modules/auth/services/sign-in-up.service.ts index ec4932d725..85028e7673 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/services/sign-in-up.service.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/services/sign-in-up.service.ts @@ -25,6 +25,7 @@ import { } from 'src/engine/core-modules/app-token/app-token.entity'; import { ApplicationService } from 'src/engine/core-modules/application/application.service'; import { BillingCreditService } from 'src/engine/core-modules/billing/services/billing-credit.service'; +import { BillingService } from 'src/engine/core-modules/billing/services/billing.service'; import { AuthException, AuthExceptionCode, @@ -93,6 +94,7 @@ export class SignInUpService { private readonly enterprisePlanService: EnterprisePlanService, private readonly eventLogEmitterService: EventLogEmitterService, private readonly billingCreditService: BillingCreditService, + private readonly billingService: BillingService, @InjectDataSource() private readonly dataSource: DataSource, ) {} @@ -708,6 +710,14 @@ export class SignInUpService { .createContext({ workspaceId }) .insertWorkspaceEvent(WORKSPACE_CREATED_EVENT, {}); + if (this.billingService.isBillingEnabled()) { + await this.billingService.ensureBillingCustomer({ + userEmail: email, + workspaceId: workspace.id, + workspaceDisplayName: workspace.displayName, + }); + } + return { user, workspace }; } catch (error) { const isSubdomainConflict = diff --git a/packages/twenty-server/src/engine/core-modules/billing/services/__test__/billing.service.spec.ts b/packages/twenty-server/src/engine/core-modules/billing/services/__test__/billing.service.spec.ts new file mode 100644 index 0000000000..7265bc8894 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/billing/services/__test__/billing.service.spec.ts @@ -0,0 +1,94 @@ +/* @license Enterprise */ + +import { Test, type TestingModule } from '@nestjs/testing'; + +import { BillingCustomerEntity } from 'src/engine/core-modules/billing/entities/billing-customer.entity'; +import { BillingSubscriptionEntity } from 'src/engine/core-modules/billing/entities/billing-subscription.entity'; +import { BillingProductService } from 'src/engine/core-modules/billing/services/billing-product.service'; +import { BillingSubscriptionService } from 'src/engine/core-modules/billing/services/billing-subscription.service'; +import { BillingService } from 'src/engine/core-modules/billing/services/billing.service'; +import { StripeCustomerService } from 'src/engine/core-modules/billing/stripe/services/stripe-customer.service'; +import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; +import { getWorkspaceScopedRepositoryToken } from 'src/engine/twenty-orm/workspace-scoped-repository/get-workspace-scoped-repository-token.util'; + +describe('BillingService', () => { + let service: BillingService; + let stripeCustomerService: jest.Mocked< + Pick + >; + let billingCustomerRepository: jest.Mocked<{ findOne: jest.Mock }>; + + const ensureParams = { + userEmail: 'user@example.com', + workspaceId: 'ws_123', + workspaceDisplayName: 'Acme', + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + BillingService, + { + provide: TwentyConfigService, + useValue: { get: jest.fn() }, + }, + { + provide: BillingSubscriptionService, + useValue: {}, + }, + { + provide: BillingProductService, + useValue: {}, + }, + { + provide: StripeCustomerService, + useValue: { + createStripeCustomer: jest + .fn() + .mockResolvedValue({ id: 'cus_123' }), + }, + }, + { + provide: getWorkspaceScopedRepositoryToken(BillingSubscriptionEntity), + useValue: { findOne: jest.fn() }, + }, + { + provide: getWorkspaceScopedRepositoryToken(BillingCustomerEntity), + useValue: { findOne: jest.fn().mockResolvedValue(null) }, + }, + ], + }).compile(); + + service = module.get(BillingService); + stripeCustomerService = module.get(StripeCustomerService); + billingCustomerRepository = module.get( + getWorkspaceScopedRepositoryToken(BillingCustomerEntity), + ); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('ensureBillingCustomer', () => { + it('creates a stripe customer when none exists', async () => { + billingCustomerRepository.findOne.mockResolvedValue(null); + + await service.ensureBillingCustomer(ensureParams); + + expect(stripeCustomerService.createStripeCustomer).toHaveBeenCalledWith( + 'user@example.com', + 'ws_123', + 'Acme', + ); + }); + + it('is idempotent when a billing customer already exists', async () => { + billingCustomerRepository.findOne.mockResolvedValue({ id: 'bc_1' }); + + await service.ensureBillingCustomer(ensureParams); + + expect(stripeCustomerService.createStripeCustomer).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/twenty-server/src/engine/core-modules/billing/services/billing.service.ts b/packages/twenty-server/src/engine/core-modules/billing/services/billing.service.ts index f509f99e9e..3df408794f 100644 --- a/packages/twenty-server/src/engine/core-modules/billing/services/billing.service.ts +++ b/packages/twenty-server/src/engine/core-modules/billing/services/billing.service.ts @@ -4,10 +4,12 @@ import { Injectable, Logger } from '@nestjs/common'; import { isDefined } from 'twenty-shared/utils'; +import { BillingCustomerEntity } from 'src/engine/core-modules/billing/entities/billing-customer.entity'; import { BillingSubscriptionEntity } from 'src/engine/core-modules/billing/entities/billing-subscription.entity'; import { type BillingEntitlementKey } from 'src/engine/core-modules/billing/enums/billing-entitlement-key.enum'; import { BillingProductService } from 'src/engine/core-modules/billing/services/billing-product.service'; import { BillingSubscriptionService } from 'src/engine/core-modules/billing/services/billing-subscription.service'; +import { StripeCustomerService } from 'src/engine/core-modules/billing/stripe/services/stripe-customer.service'; import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; import { InjectWorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/inject-workspace-scoped-repository.decorator'; import { WorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/workspace-scoped-repository'; @@ -18,14 +20,40 @@ export class BillingService { private readonly twentyConfigService: TwentyConfigService, private readonly billingSubscriptionService: BillingSubscriptionService, private readonly billingProductService: BillingProductService, + private readonly stripeCustomerService: StripeCustomerService, @InjectWorkspaceScopedRepository(BillingSubscriptionEntity) private readonly billingSubscriptionRepository: WorkspaceScopedRepository, + @InjectWorkspaceScopedRepository(BillingCustomerEntity) + private readonly billingCustomerRepository: WorkspaceScopedRepository, ) {} isBillingEnabled() { return this.twentyConfigService.get('IS_BILLING_ENABLED'); } + async ensureBillingCustomer({ + userEmail, + workspaceId, + workspaceDisplayName, + }: { + userEmail: string; + workspaceId: string; + workspaceDisplayName: string | undefined; + }): Promise { + const existingBillingCustomer = + await this.billingCustomerRepository.findOne(workspaceId, { where: {} }); + + if (isDefined(existingBillingCustomer)) { + return; + } + + await this.stripeCustomerService.createStripeCustomer( + userEmail, + workspaceId, + workspaceDisplayName, + ); + } + async hasWorkspaceAnySubscription(workspaceId: string) { const isBillingEnabled = this.isBillingEnabled();