Count self-hosted billable seats per distinct user (#22986)

## Context

Self-hosted enterprise pricing is documented as per user ("Pricing is
per user and each user needs a licence"), but seat counts reported to
the enterprise API counted active `userWorkspace` rows. A user belonging
to two workspaces on the same instance was billed as two seats.

## What this does

- Adds `EnterprisePlanService.getBillableSeatCount()`, which counts
`DISTINCT userId` over non-deleted userWorkspaces, keeping the existing
floor of 1.
- Uses it at all four seat-reporting sites: the checkout session
quantity, the seat reports on key activation (`setEnterpriseKey`) and
server-binding release, and the recurring validation cron report.
- Removes the two duplicated private `getActiveUserWorkspaceCount()`
counters and their `UserWorkspaceEntity` repository injections from the
resolver and cron job.
- Adds unit tests for the new method (dedup across workspaces, floor of
1, missing row).

## Intentionally unchanged

- The website `/api/enterprise/seats` and `/checkout` routes apply
whatever quantity the instance reports, so no Stripe-side change is
needed.
- Instance telemetry still reports both `activeUserWorkspaceCount` and
`distinctUserCount`, so the delta stays observable.
- Existing subscriptions need no migration: the next cron seat report
prorates affected instances down automatically.

## Test

- `enterprise-plan.service.spec.ts`: 58 passed (3 new)
- `lint:diff-with-main` and `typecheck` for twenty-server pass

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

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22986?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. -->
This commit is contained in:
Félix Malfait
2026-07-17 16:16:12 +02:00
committed by GitHub
parent 19f0e3cad5
commit aafee63706
4 changed files with 66 additions and 38 deletions
@@ -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<UserWorkspaceEntity>,
) {}
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<number> {
const count = await this.userWorkspaceRepository.count({
where: { deletedAt: IsNull() },
});
return Math.max(1, count);
}
}
@@ -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<UserWorkspaceEntity>,
) {}
private async getActiveUserWorkspaceCount(): Promise<number> {
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<string | null> {
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);
@@ -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);
@@ -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<number> {
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<boolean> {
const enterpriseKey = this.twentyConfigService.get('ENTERPRISE_KEY');