diff --git a/src/backend/data/subPolicies/index.ts b/src/backend/data/subPolicies/index.ts index 5e34f04f8..4f702a2c6 100644 --- a/src/backend/data/subPolicies/index.ts +++ b/src/backend/data/subPolicies/index.ts @@ -17,7 +17,12 @@ * along with this program. If not, see . */ +import { ORG_SEAT_FREE } from './orgSeatFreePolicy.js'; import { REGISTERED_USER_FREE } from './registeredUserFreePolicy.js'; import { TEMP_USER_FREE } from './tempUserFreePolicy.js'; -export const SUB_POLICIES = [TEMP_USER_FREE, REGISTERED_USER_FREE]; +export const SUB_POLICIES = [ + TEMP_USER_FREE, + REGISTERED_USER_FREE, + ORG_SEAT_FREE, +]; diff --git a/src/backend/data/subPolicies/orgSeatFreePolicy.ts b/src/backend/data/subPolicies/orgSeatFreePolicy.ts new file mode 100644 index 000000000..70f6d1904 --- /dev/null +++ b/src/backend/data/subPolicies/orgSeatFreePolicy.ts @@ -0,0 +1,32 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import { ORG_SEAT_FREE_SUBSCRIPTION } from '../../services/metering/consts.js'; +import { REGISTERED_USER_FREE } from './registeredUserFreePolicy.js'; + +/** Half the free plan, so a team cannot mint full free tiers by provisioning. */ +export const ORG_SEAT_FREE = { + id: ORG_SEAT_FREE_SUBSCRIPTION, + monthUsageAllowance: Math.floor( + REGISTERED_USER_FREE.monthUsageAllowance / 2, + ), + monthlyStorageAllowance: Math.floor( + REGISTERED_USER_FREE.monthlyStorageAllowance / 2, + ), +}; diff --git a/src/backend/services/metering/consts.ts b/src/backend/services/metering/consts.ts index 568ff501e..02905ff9c 100644 --- a/src/backend/services/metering/consts.ts +++ b/src/backend/services/metering/consts.ts @@ -32,6 +32,7 @@ export const PERIOD_ESCAPE = '_dot_'; export const MONTHLY_CHARGE_CLAIM = 'monthlyChargesApplied'; export const DEFAULT_FREE_SUBSCRIPTION = 'user_free'; export const DEFAULT_TEMP_SUBSCRIPTION = 'temp_free'; +export const ORG_SEAT_FREE_SUBSCRIPTION = 'org_seat_free'; /** * The policies an account holds without paying for anything. Everything else — @@ -44,6 +45,7 @@ export const DEFAULT_TEMP_SUBSCRIPTION = 'temp_free'; export const FREE_SUBSCRIPTION_IDS: ReadonlySet = new Set([ DEFAULT_FREE_SUBSCRIPTION, DEFAULT_TEMP_SUBSCRIPTION, + ORG_SEAT_FREE_SUBSCRIPTION, ]); // WARNING: DO NOT USE THESE IN PROD diff --git a/src/backend/services/team/TeamService.test.ts b/src/backend/services/team/TeamService.test.ts index 3f8b7b3be..05d8dc51e 100644 --- a/src/backend/services/team/TeamService.test.ts +++ b/src/backend/services/team/TeamService.test.ts @@ -18,7 +18,17 @@ */ import { v4 as uuidv4 } from 'uuid'; -import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { subscriptionSatisfies } from '../metering/enforcement.js'; +import { REGISTERED_USER_FREE } from '../../data/subPolicies/registeredUserFreePolicy.js'; +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + it, +} from 'vitest'; import { PuterServer } from '../../server.ts'; import { setupTestServer } from '../../testUtil.ts'; @@ -27,6 +37,7 @@ describe('TeamService', () => { let service: PuterServer['services']['team']; let owner: { id: number }; let ownerUsername: string; + let ownerUuid: string; const makeUser = async (): Promise<{ id: number; username: string }> => { const username = `svc_${Math.random().toString(36).slice(2, 10)}`; @@ -72,10 +83,13 @@ describe('TeamService', () => { server = await setupTestServer({ teams_enabled: true, max_teams_per_user: 100, + max_seats_per_team: 100, } as never); service = server.services.team; owner = await makeUser(); - ownerUsername = (await server.stores.user.getById(owner.id))!.username; + const ownerRow = (await server.stores.user.getById(owner.id))!; + ownerUsername = ownerRow.username; + ownerUuid = ownerRow.uuid; }); afterAll(async () => { @@ -644,6 +658,98 @@ describe('TeamService', () => { } }); + describe('what a seat of a free team gets', () => { + const policyFor = async (userId: number, uuid: string) => { + server.services.metering.invalidateActorSubscription(uuid); + return server.services.metering.getActorSubscription({ + user: { id: userId, uuid }, + } as never); + }; + + it('resolves half the free plan', async () => { + const { team } = await makeTeam(); + const created = await service.provisionAccount(team.uid, owner.id, { + username: `half_${Math.random().toString(36).slice(2, 9)}`, + }); + const row = (await server.stores.user.getById(created.userId))!; + const seat = await policyFor(created.userId, row.uuid); + + expect(seat.id).toBe('org_seat_free'); + expect(seat.monthUsageAllowance).toBe( + Math.floor(REGISTERED_USER_FREE.monthUsageAllowance / 2), + ); + expect(seat.monthlyStorageAllowance).toBe( + Math.floor(REGISTERED_USER_FREE.monthlyStorageAllowance / 2), + ); + }); + + it('leaves anyone who is not a seat alone', async () => { + const plain = await policyFor(owner.id, ownerUuid); + expect(plain.id).not.toBe('org_seat_free'); + }); + + it('still counts as free, so plan gates refuse it', async () => { + // Nobody paid for it; it must not satisfy `requireSubscription`. + expect(subscriptionSatisfies('org_seat_free', true)).toBe(false); + }); + }); + + describe('the seat cap', () => { + // 0 clears the absolute override, so the plan-derived caps apply. + const cfg = () => + (service as unknown as { config: Record }).config; + beforeEach(() => { + cfg().max_seats_per_team = 0; + }); + afterEach(() => { + cfg().max_seats_per_team = 100; + }); + + const addSeat = (teamUid: string) => + service.provisionAccount(teamUid, owner.id, { + username: `cap_${Math.random().toString(36).slice(2, 9)}`, + }); + + // makeTeam seeds one seat, so three more reaches four. + const fillToFour = async (teamUid: string) => { + for (let i = 0; i < 3; i++) await addSeat(teamUid); + }; + + it('stops a free owner at four seats', async () => { + const { team } = await makeTeam(); + await fillToFour(team.uid); + await expect(addSeat(team.uid)).rejects.toMatchObject({ + statusCode: 409, + fields: { limit: 4 }, + }); + }); + + it('lets a paid owner past four', async () => { + const { team } = await makeTeam(); + const metering = server.services.metering as unknown as { + registerPolicy: (p: Record) => void; + registerSubscriptionResolver: (fn: unknown) => void; + invalidateActorSubscription: (uuid: string) => void; + }; + // A resolver naming an unregistered policy falls back to free. + metering.registerPolicy({ + id: 'business', + monthUsageAllowance: 4_500_000_000, + monthlyStorageAllowance: 2_147_483_648_000, + }); + metering.registerSubscriptionResolver( + (actor: { user?: { id?: number } }) => + actor?.user?.id === owner.id ? 'business' : null, + ); + metering.invalidateActorSubscription(ownerUuid); + + await fillToFour(team.uid); + await expect(addSeat(team.uid)).resolves.toMatchObject({ + username: expect.any(String), + }); + }); + }); + it('refuses an invalid email', async () => { const { team } = await makeTeam(); await expect( diff --git a/src/backend/services/team/TeamService.ts b/src/backend/services/team/TeamService.ts index 7afe3c51b..efa2db3c0 100644 --- a/src/backend/services/team/TeamService.ts +++ b/src/backend/services/team/TeamService.ts @@ -26,6 +26,13 @@ import { USERNAME_REGEX, } from '../../controllers/auth/AuthController.js'; import type { EmailTemplateName } from '../../clients/email/templates.js'; +import { subscriptionSatisfies } from '../metering/enforcement.js'; +import { ORG_SEAT_FREE_SUBSCRIPTION } from '../metering/consts.js'; + +// A free team is small on purpose; paying widens it. Both overridable in config. +const FREE_SEAT_CAP = 4; +const PAID_SEAT_CAP = 40; + import type { EventMap, TeamBillingContext, @@ -114,6 +121,20 @@ const epochSeconds = (value: unknown): number => { }; export class TeamService extends PuterService { + /** Half the free plan for a seat, unless a paid tier outranks it. */ + override async onServerStart(): Promise { + if (this.config.teams_enabled !== true) return; + this.services.metering.registerDefaultSubscriptionResolver( + async (actor) => { + const userId = actor?.user?.id; + if (typeof userId !== 'number') return null; + // Cached, negatives included: almost nothing is a seat. + const seat = await this.stores.team.getOrgSeat(userId); + return seat ? ORG_SEAT_FREE_SUBSCRIPTION : null; + }, + ); + } + // -- Billing ---- OSS emits; prod decides (see TEAMS-BILLING-SPLIT) ---- /** The team owner pays, so the charge is keyed to its customer id. */ @@ -231,10 +252,34 @@ export class TeamService extends PuterService { return Number.isFinite(n) && n > 0 ? n : 1; } - /** Seats one team may provision. Adjustable without a code change. */ - #seatCap(): number { - const n = Number(this.config.max_seats_per_team); - return Number.isFinite(n) && n > 0 ? n : 50; + /** Depends on whether the owner pays; `max_seats_per_team` overrides both. */ + async #seatCap(ownerUserId: number): Promise { + const override = Number(this.config.max_seats_per_team); + if (Number.isFinite(override) && override > 0) return override; + + const paid = await this.#ownerPays(ownerUserId); + const key = paid + ? 'max_seats_per_team_paid' + : 'max_seats_per_team_free'; + const n = Number(this.config[key]); + if (Number.isFinite(n) && n > 0) return n; + return paid ? PAID_SEAT_CAP : FREE_SEAT_CAP; + } + + /** A resolved policy outside the free set is a plan someone is paying for. */ + async #ownerPays(ownerUserId: number): Promise { + try { + const owner = await this.stores.user.getById(ownerUserId); + if (!owner?.uuid) return false; + const policy = await this.services.metering.getActorSubscription({ + user: { id: owner.id, uuid: owner.uuid }, + } as never); + return subscriptionSatisfies(policy.id, true); + } catch (e) { + // Smaller cap on an unreadable plan: over-provisioning is worse. + console.warn('[team] seat cap plan lookup failed:', e); + return false; + } } /** A rejected handle is 400, a taken one 409, never an unhandled 500. */ @@ -723,7 +768,7 @@ export class TeamService extends PuterService { const team = await this.requireOwner(teamUid, actorUserId); // Counted, never derived from a stored total: seats come and go. - const cap = this.#seatCap(); + const cap = await this.#seatCap(team.owner_user_id); if ((await this.stores.team.countSeats(team.id)) >= cap) { throw new HttpError(409, `This team is limited to ${cap} seats`, { legacyCode: 'seat_limit_reached',