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:
+2
-18
@@ -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);
|
||||
|
||||
|
||||
+48
-1
@@ -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);
|
||||
|
||||
+12
@@ -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');
|
||||
|
||||
|
||||
Reference in New Issue
Block a user