Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions packages/backend/src/audit/product-event.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -356,6 +356,10 @@ export const PAYWALL_ACTIONS = [
'support_clicked',
'delete_account_clicked',
'left_without_subscribe',
// The "What changes with <plan>" block was shown because the workspace is
// above that plan (metadata.plan), and its list of items was opened.
'plan_fit_shown',
'plan_fit_details_opened',
] as const;
export type PaywallAction = (typeof PAYWALL_ACTIONS)[number];

Expand Down
9 changes: 9 additions & 0 deletions packages/backend/src/license/license-guard.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -156,4 +156,13 @@ describe('LicenseGuardService.getUsage', () => {
expect(usage.mcpServers).toEqual({ current: 2, max: 3, isOver: false });
expect(usage.isOverAny).toBe(true);
});

it('counts only active connectors and servers, File Search folders apart', async () => {
const { guard, prisma } = make({ maxConnectors: 5, maxMcpServers: 3, maxUsers: 1 });
await guard.getUsage('u1', 'org_2');
expect(prisma.connector.count).toHaveBeenCalledWith({
where: { organizationId: 'org_2', isActive: true, type: { not: 'FILE_SEARCH' } },
});
expect(prisma.mcpServerConfig.count).toHaveBeenCalledWith({ where: { organizationId: 'org_2', isActive: true } });
});
});
17 changes: 13 additions & 4 deletions packages/backend/src/license/license-guard.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,15 @@ export interface TrialState {
cardTrialAvailable: boolean;
}

/**
* The connectors and MCP servers that count against a plan's limits: active
* ones only (a paused one can be swapped back in), and File Search folders
* apart, since File Search has its own limits per plan. `PlanFitService`
* counts the same way.
*/
const PLAN_COUNTED_CONNECTOR = { isActive: true, type: { not: 'FILE_SEARCH' as const } };
const PLAN_COUNTED_SERVER = { isActive: true };

@Injectable()
export class LicenseGuardService {
constructor(
Expand Down Expand Up @@ -113,7 +122,7 @@ export class LicenseGuardService {
if (maxConnectors == null) return;

const count = await this.prisma.connector.count({
where: organizationId ? { organizationId } : { userId },
where: { ...(organizationId ? { organizationId } : { userId }), ...PLAN_COUNTED_CONNECTOR },
});
if (count >= maxConnectors) {
throw new ForbiddenException(
Expand All @@ -133,7 +142,7 @@ export class LicenseGuardService {
if (maxMcpServers == null) return;

const count = await this.prisma.mcpServerConfig.count({
where: organizationId ? { organizationId } : { userId },
where: { ...(organizationId ? { organizationId } : { userId }), ...PLAN_COUNTED_SERVER },
});
if (count >= maxMcpServers) {
throw new ForbiddenException(
Expand Down Expand Up @@ -227,8 +236,8 @@ export class LicenseGuardService {
const where = organizationId ? { organizationId } : userId ? { userId } : undefined;

const [connectorCount, mcpCount, userCount] = await Promise.all([
where ? this.prisma.connector.count({ where }) : Promise.resolve(0),
where ? this.prisma.mcpServerConfig.count({ where }) : Promise.resolve(0),
where ? this.prisma.connector.count({ where: { ...where, ...PLAN_COUNTED_CONNECTOR } }) : Promise.resolve(0),
where ? this.prisma.mcpServerConfig.count({ where: { ...where, ...PLAN_COUNTED_SERVER } }) : Promise.resolve(0),
// Active members of the workspace, not users whose primary workspace it
// is: someone invited into a second workspace keeps their first one as
// User.organizationId, and a deactivated member holds no seat.
Expand Down
45 changes: 44 additions & 1 deletion packages/backend/src/license/license.controller.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { NotFoundException } from '@nestjs/common';
import { BadRequestException, NotFoundException } from '@nestjs/common';
import { LicenseController } from './license.controller';
import { readEmailLinkToken } from '../common/email-link-token';

Expand Down Expand Up @@ -271,3 +271,46 @@ describe('LicenseController — the licence site reports a changed licence', ()
await expect(controller.syncFromSite(req(TOKEN), { licenseKey: KEY })).resolves.toEqual({ synced: false });
});
});

describe('LicenseController — plan fit', () => {
function make(cloud = true) {
const planFit = {
fit: jest.fn(async (_org: string, plan: string) => ({ plan, fits: true })),
fitAll: jest.fn(async () => ({ starter: { plan: 'starter', fits: false }, team: { plan: 'team', fits: true } })),
};
const controller = new LicenseController(
{} as any,
{} as any,
{} as any,
{} as any,
{ isCloud: () => cloud } as any,
{} as any,
{} as any,
{} as any,
undefined,
planFit as any,
);
return { controller, planFit };
}
const req = { user: { organizationId: 'org-1', sub: 'u1', role: 'MEMBER' } };

it('returns both plans for the caller\'s workspace', async () => {
const { controller, planFit } = make();
await expect(controller.planFitForUser(req)).resolves.toEqual({
plans: { starter: { plan: 'starter', fits: false }, team: { plan: 'team', fits: true } },
});
expect(planFit.fitAll).toHaveBeenCalledWith('org-1');
});

it('returns one plan on request and refuses others', async () => {
const { controller, planFit } = make();
await expect(controller.planFitForUser(req, 'team')).resolves.toEqual({ plans: { team: { plan: 'team', fits: true } } });
expect(planFit.fit).toHaveBeenCalledWith('org-1', 'team');
await expect(controller.planFitForUser(req, 'enterprise')).rejects.toThrow(BadRequestException);
});

it('is Cloud only and needs a workspace', async () => {
await expect(make(false).controller.planFitForUser(req)).rejects.toThrow(NotFoundException);
await expect(make().controller.planFitForUser({ user: {} })).rejects.toThrow(NotFoundException);
});
});
23 changes: 23 additions & 0 deletions packages/backend/src/license/license.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
HttpCode,
Logger,
Optional,
Query,
} from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth, ApiSecurity, ApiExcludeEndpoint } from '@nestjs/swagger';
import { timingSafeEqual } from 'crypto';
Expand All @@ -30,6 +31,8 @@ import { ProductEventService, ProductEvents } from '../audit/product-event.servi
import { CardTrialEligibilityService } from './card-trial-eligibility.service';
import { CardTrialOfferService } from './card-trial-offer.service';
import { LicenseWallService, type LicenseWallView } from './license-wall.service';
import { PlanFitService } from './plan-fit.service';
import { isFitPlan, type FitPlan, type PlanFit } from './plan-fit';
import { createEmailLinkToken } from '../common/email-link-token';
import {
CHECKOUT_BILLING_PERIODS,
Expand Down Expand Up @@ -115,6 +118,7 @@ export class LicenseController {
private readonly cardTrialEligibility: CardTrialEligibilityService,
private readonly cardTrialOffer: CardTrialOfferService,
@Optional() private readonly licenseWall?: LicenseWallService,
@Optional() private readonly planFit?: PlanFitService,
) {}

@Get('status')
Expand Down Expand Up @@ -483,6 +487,25 @@ export class LicenseController {
return { offer };
}

@Get('plan-fit')
@UseGuards(JwtOrManagementKeyGuard)
@Throttle({ default: { limit: 30, ttl: 60_000 } })
@ApiBearerAuth()
@ApiSecurity('api-key')
@ApiOperation({
summary:
'Cloud: what this workspace keeps and loses on Starter and Team (connectors, MCP servers, users, File Search, code mode), with the connectors and servers that would stay active or be paused',
})
async planFitForUser(@Req() req: any, @Query('plan') plan?: string): Promise<{ plans: Partial<Record<FitPlan, PlanFit>> }> {
// Plan limits are a Cloud matter: self-hosted has none of these caps.
const organizationId: string | undefined = req.user?.organizationId;
if (!this.planFit || !this.deployment.isCloud() || !organizationId) throw new NotFoundException();
if (plan !== undefined && !isFitPlan(plan)) throw new BadRequestException('plan must be starter or team');
if (plan === 'starter') return { plans: { starter: await this.planFit.fit(organizationId, 'starter') } };
if (plan === 'team') return { plans: { team: await this.planFit.fit(organizationId, 'team') } };
return { plans: await this.planFit.fitAll(organizationId) };
}

@Get('wall')
@UseGuards(JwtOrManagementKeyGuard)
@ApiBearerAuth()
Expand Down
6 changes: 5 additions & 1 deletion packages/backend/src/license/license.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,11 @@ import { TrialExtensionController } from './trial-extension.controller';
import { ReactivationController } from './reactivation.controller';
import { SettingsModule } from '../settings/settings.module';
import { UsersModule } from '../users/users.module';
import { FileSearchCoreModule } from '../file-search/file-search-core.module';
import { PlanFitService } from './plan-fit.service';

@Module({
imports: [SettingsModule, UsersModule],
imports: [SettingsModule, UsersModule, FileSearchCoreModule],
controllers: [LicenseController, TrialExtensionController, ReactivationController],
providers: [
LicenseService,
Expand All @@ -21,13 +23,15 @@ import { UsersModule } from '../users/users.module';
CardTrialOfferService,
LicenseWallService,
RetentionLicenceClient,
PlanFitService,
],
exports: [
LicenseService,
LicenseGuardService,
CardTrialEligibilityService,
CardTrialOfferService,
RetentionLicenceClient,
PlanFitService,
],
})
export class LicenseModule {}
90 changes: 90 additions & 0 deletions packages/backend/src/license/plan-fit.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../common/prisma.service';
import { FileSearchRepository } from '../file-search/file-search.repository';
import { startOfUtcMonth } from './code-run-quotas';
import { evaluatePlanFit, FIT_PLANS, type FitItem, type FitPlan, type FitSnapshot, type PlanFit } from './plan-fit';

const DAY_MS = 24 * 60 * 60 * 1000;

/**
* Reads what a workspace uses today and evaluates it against the Cloud plans
* (see `plan-fit.ts`). Every query is scoped to the organization.
*/
@Injectable()
export class PlanFitService {
constructor(
private readonly prisma: PrismaService,
private readonly fileRepo: FileSearchRepository,
) {}

async fit(organizationId: string, plan: FitPlan, now = new Date()): Promise<PlanFit> {
return evaluatePlanFit(await this.snapshot(organizationId, now), plan);
}

/** Both checkout plans from one read. */
async fitAll(organizationId: string, now = new Date()): Promise<Record<FitPlan, PlanFit>> {
const snap = await this.snapshot(organizationId, now);
return Object.fromEntries(FIT_PLANS.map((p) => [p, evaluatePlanFit(snap, p)])) as Record<FitPlan, PlanFit>;
}

async snapshot(organizationId: string, now = new Date()): Promise<FitSnapshot> {
const since = new Date(now.getTime() - 30 * DAY_MS);
const [connectors, servers, users, byConnector, byServer, codeTools, runs, files] = await Promise.all([
this.prisma.connector.findMany({
where: { organizationId, isActive: true },
select: { id: true, name: true, type: true, config: true, updatedAt: true },
}),
this.prisma.mcpServerConfig.findMany({
where: { organizationId, isActive: true },
select: { id: true, name: true, codeMode: true, updatedAt: true },
}),
this.prisma.organizationMember.count({ where: { organizationId, deactivatedAt: null } }),
this.prisma.toolInvocation.groupBy({
by: ['connectorId'],
where: { organizationId, createdAt: { gte: since }, connectorId: { not: null } },
_count: { _all: true },
}),
this.prisma.toolInvocation.groupBy({
by: ['mcpServerId'],
where: { organizationId, createdAt: { gte: since }, mcpServerId: { not: null } },
_count: { _all: true },
}),
this.prisma.$queryRaw<{ count: number }[]>`
SELECT COUNT(*)::int AS count
FROM mcp_tools t
JOIN connectors c ON c.id = t.connector_id
WHERE c.organization_id = ${organizationId}
AND t.endpoint_mapping->>'method' = 'code'`,
this.prisma.codeRun.count({ where: { organizationId, createdAt: { gte: startOfUtcMonth(now) } } }),
this.fileRepo
.withTenant(organizationId, (tx) =>
tx.fileEntry.aggregate({ where: { organizationId }, _count: true, _sum: { size: true } }),
)
.catch(() => null),
]);

const connectorCalls = new Map(byConnector.map((r) => [r.connectorId as string, r._count._all]));
const serverCalls = new Map(byServer.map((r) => [r.mcpServerId as string, r._count._all]));
const item = (calls: Map<string, number>) => (c: { id: string; name: string; updatedAt: Date }): FitItem => ({
id: c.id,
name: c.name,
calls30d: calls.get(c.id) ?? 0,
updatedAt: c.updatedAt,
});

const isFileSearch = (c: { type: unknown }) => String(c.type) === 'FILE_SEARCH';
const isFileShare = (c: { config: unknown }) => !!(c.config as { fileShare?: unknown } | null)?.fileShare;

return {
connectors: connectors.filter((c) => !isFileSearch(c)).map(item(connectorCalls)),
mcpServers: servers.map(item(serverCalls)),
users,
fileSearchConnectors: connectors.filter((c) => isFileSearch(c) && !isFileShare(c)).map(item(connectorCalls)),
fileSearchFiles: files?._count ?? 0,
fileSearchBytes: Number(files?._sum.size ?? 0),
codeTools: codeTools[0]?.count ?? 0,
inlineCodeInUse: servers.some((s) => s.codeMode === 'inline'),
codeRunsThisMonth: runs,
};
}
}
Loading
Loading