diff --git a/packages/twenty-server/src/engine/core-modules/enterprise/cron/jobs/enterprise-key-validation.cron.job.ts b/packages/twenty-server/src/engine/core-modules/enterprise/cron/jobs/enterprise-key-validation.cron.job.ts index 6c99f375fa..e055b5aa31 100644 --- a/packages/twenty-server/src/engine/core-modules/enterprise/cron/jobs/enterprise-key-validation.cron.job.ts +++ b/packages/twenty-server/src/engine/core-modules/enterprise/cron/jobs/enterprise-key-validation.cron.job.ts @@ -1,9 +1,6 @@ /* @license Enterprise */ import { Injectable, Logger } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; - -import { IsNull, Repository } from 'typeorm'; import { SentryCronMonitor } from 'src/engine/core-modules/cron/sentry-cron-monitor.decorator'; import { ENTERPRISE_KEY_VALIDATION_CRON_PATTERN } from 'src/engine/core-modules/enterprise/constants/enterprise-key-validation-cron-pattern.constant'; @@ -11,18 +8,13 @@ import { EnterprisePlanService } from 'src/engine/core-modules/enterprise/servic import { Process } from 'src/engine/core-modules/message-queue/decorators/process.decorator'; import { Processor } from 'src/engine/core-modules/message-queue/decorators/processor.decorator'; import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants'; -import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity'; @Injectable() @Processor(MessageQueue.cronQueue) export class EnterpriseKeyValidationCronJob { private readonly logger = new Logger(EnterpriseKeyValidationCronJob.name); - constructor( - private readonly enterprisePlanService: EnterprisePlanService, - @InjectRepository(UserWorkspaceEntity) - private readonly userWorkspaceRepository: Repository, - ) {} + constructor(private readonly enterprisePlanService: EnterprisePlanService) {} @Process(EnterpriseKeyValidationCronJob.name) @SentryCronMonitor( @@ -54,7 +46,7 @@ export class EnterpriseKeyValidationCronJob { } try { - const seatCount = await this.getActiveUserWorkspaceCount(); + const seatCount = await this.enterprisePlanService.getBillableSeatCount(); const reportSuccess = await this.enterprisePlanService.reportSeats(seatCount); @@ -70,12 +62,4 @@ export class EnterpriseKeyValidationCronJob { ); } } - - private async getActiveUserWorkspaceCount(): Promise { - const count = await this.userWorkspaceRepository.count({ - where: { deletedAt: IsNull() }, - }); - - return Math.max(1, count); - } } diff --git a/packages/twenty-server/src/engine/core-modules/enterprise/enterprise.resolver.ts b/packages/twenty-server/src/engine/core-modules/enterprise/enterprise.resolver.ts index 7127babced..6010fcec02 100644 --- a/packages/twenty-server/src/engine/core-modules/enterprise/enterprise.resolver.ts +++ b/packages/twenty-server/src/engine/core-modules/enterprise/enterprise.resolver.ts @@ -2,9 +2,7 @@ import { UseFilters, UseGuards, UsePipes } from '@nestjs/common'; import { Args, Mutation, Query, Resolver } from '@nestjs/graphql'; -import { InjectRepository } from '@nestjs/typeorm'; -import { IsNull, Repository } from 'typeorm'; import { isDefined } from 'twenty-shared/utils'; import { EnterpriseLicenseInfoDTO } from 'src/engine/core-modules/enterprise/dtos/enterprise-license-info.dto'; @@ -18,7 +16,6 @@ import { EnterprisePlanService } from 'src/engine/core-modules/enterprise/servic import { PreventNestToAutoLogGraphqlErrorsFilter } from 'src/engine/core-modules/graphql/filters/prevent-nest-to-auto-log-graphql-errors.filter'; import { ResolverValidationPipe } from 'src/engine/core-modules/graphql/pipes/resolver-validation.pipe'; import { ConfigVariableExceptionCode } from 'src/engine/core-modules/twenty-config/twenty-config.exception'; -import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity'; import { AdminPanelGuard } from 'src/engine/guards/admin-panel-guard'; import { BillingDisabledGuard } from 'src/engine/guards/billing-disabled.guard'; import { NoPermissionGuard } from 'src/engine/guards/no-permission.guard'; @@ -37,19 +34,7 @@ const SERVER_BINDING_REJECTION_CODES: EnterpriseExceptionCode[] = [ @UsePipes(ResolverValidationPipe) @UseFilters(EnterpriseExceptionFilter, PreventNestToAutoLogGraphqlErrorsFilter) export class EnterpriseResolver { - constructor( - private readonly enterprisePlanService: EnterprisePlanService, - @InjectRepository(UserWorkspaceEntity) - private readonly userWorkspaceRepository: Repository, - ) {} - - private async getActiveUserWorkspaceCount(): Promise { - const count = await this.userWorkspaceRepository.count({ - where: { deletedAt: IsNull() }, - }); - - return Math.max(1, count); - } + constructor(private readonly enterprisePlanService: EnterprisePlanService) {} // Turn a server-binding rejection from the last refresh into a user-facing // error, so activation and manual refresh surface the real reason instead of @@ -97,7 +82,7 @@ export class EnterpriseResolver { @Args('billingInterval', { nullable: true }) billingInterval?: string, ): Promise { const interval = billingInterval === 'yearly' ? 'yearly' : 'monthly'; - const seatCount = await this.getActiveUserWorkspaceCount(); + const seatCount = await this.enterprisePlanService.getBillableSeatCount(); return this.enterprisePlanService.getCheckoutUrl(interval, seatCount); } @@ -140,7 +125,7 @@ export class EnterpriseResolver { await this.enterprisePlanService.refreshValidityToken(); - const seatCount = await this.getActiveUserWorkspaceCount(); + const seatCount = await this.enterprisePlanService.getBillableSeatCount(); await this.enterprisePlanService.reportSeats(seatCount); @@ -173,7 +158,7 @@ export class EnterpriseResolver { this.throwIfServerBindingRejected(); - const seatCount = await this.getActiveUserWorkspaceCount(); + const seatCount = await this.enterprisePlanService.getBillableSeatCount(); await this.enterprisePlanService.reportSeats(seatCount); diff --git a/packages/twenty-server/src/engine/core-modules/enterprise/services/__tests__/enterprise-plan.service.spec.ts b/packages/twenty-server/src/engine/core-modules/enterprise/services/__tests__/enterprise-plan.service.spec.ts index 309ad3ca35..39dcede81f 100644 --- a/packages/twenty-server/src/engine/core-modules/enterprise/services/__tests__/enterprise-plan.service.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/enterprise/services/__tests__/enterprise-plan.service.spec.ts @@ -67,6 +67,12 @@ describe('EnterprisePlanService', () => { const workspaceCountMock = jest.fn(); const userCountMock = jest.fn(); const userWorkspaceCountMock = jest.fn(); + const userWorkspaceGetRawOneMock = jest.fn(); + const userWorkspaceQueryBuilderMock = { + select: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + getRawOne: userWorkspaceGetRawOneMock, + }; const userFindOneMock = jest.fn(); let originalFetch: typeof global.fetch; @@ -119,6 +125,9 @@ describe('EnterprisePlanService', () => { workspaceCountMock.mockResolvedValue(0); userCountMock.mockResolvedValue(0); userWorkspaceCountMock.mockResolvedValue(0); + userWorkspaceQueryBuilderMock.select.mockReturnThis(); + userWorkspaceQueryBuilderMock.where.mockReturnThis(); + userWorkspaceGetRawOneMock.mockResolvedValue({ distinctUserCount: '0' }); userFindOneMock.mockResolvedValue(null); const module: TestingModule = await Test.createTestingModule({ @@ -143,7 +152,12 @@ describe('EnterprisePlanService', () => { }, { provide: getRepositoryToken(UserWorkspaceEntity), - useValue: { count: userWorkspaceCountMock }, + useValue: { + count: userWorkspaceCountMock, + createQueryBuilder: jest + .fn() + .mockReturnValue(userWorkspaceQueryBuilderMock), + }, }, { provide: getRepositoryToken(UserEntity), @@ -623,6 +637,39 @@ describe('EnterprisePlanService', () => { }); }); + describe('getBillableSeatCount', () => { + it('should count distinct users so a user in several workspaces is one seat', async () => { + userWorkspaceGetRawOneMock.mockResolvedValue({ distinctUserCount: '5' }); + + const result = await service.getBillableSeatCount(); + + expect(result).toBe(5); + expect(userWorkspaceQueryBuilderMock.select).toHaveBeenCalledWith( + 'COUNT(DISTINCT "userWorkspace"."userId")', + 'distinctUserCount', + ); + expect(userWorkspaceQueryBuilderMock.where).toHaveBeenCalledWith( + '"userWorkspace"."deletedAt" IS NULL', + ); + }); + + it('should return at least 1 when there are no active user workspaces', async () => { + userWorkspaceGetRawOneMock.mockResolvedValue({ distinctUserCount: '0' }); + + const result = await service.getBillableSeatCount(); + + expect(result).toBe(1); + }); + + it('should return at least 1 when the query returns no row', async () => { + userWorkspaceGetRawOneMock.mockResolvedValue(undefined); + + const result = await service.getBillableSeatCount(); + + expect(result).toBe(1); + }); + }); + describe('reportSeats', () => { it('should return false when no enterprise key is configured', async () => { setupEnterpriseKey(undefined); diff --git a/packages/twenty-server/src/engine/core-modules/enterprise/services/enterprise-plan.service.ts b/packages/twenty-server/src/engine/core-modules/enterprise/services/enterprise-plan.service.ts index a9c3da9f01..5be37bd523 100644 --- a/packages/twenty-server/src/engine/core-modules/enterprise/services/enterprise-plan.service.ts +++ b/packages/twenty-server/src/engine/core-modules/enterprise/services/enterprise-plan.service.ts @@ -337,6 +337,18 @@ export class EnterprisePlanService implements OnModuleInit { } } + // Self-hosted pricing is per user: a user who belongs to several workspaces + // on the same instance only counts as one seat. + async getBillableSeatCount(): Promise { + const result = await this.userWorkspaceRepository + .createQueryBuilder('userWorkspace') + .select('COUNT(DISTINCT "userWorkspace"."userId")', 'distinctUserCount') + .where('"userWorkspace"."deletedAt" IS NULL') + .getRawOne<{ distinctUserCount: string }>(); + + return Math.max(1, Number(result?.distinctUserCount ?? 0)); + } + async reportSeats(seatCount: number): Promise { const enterpriseKey = this.twentyConfigService.get('ENTERPRISE_KEY');