From 7ab48c590eb4087a01e8ffe7c828b5e219cb13cf Mon Sep 17 00:00:00 2001 From: Juan Castro Date: Wed, 9 Sep 2026 13:07:35 -0400 Subject: [PATCH] feat: add TeamStore.countActiveSeats for the billable seat count MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per-account billing needs "how many accounts is this team getting the benefit of", and neither existing count answers it. `countSeats` includes suspended seats, because it bounds the seat cap and a suspended seat still holds its username. `countPayers` counts the owner. Billing off `countSeats` overcharges for suspended accounts, contradicting what the admin console tells the owner: "0 suspended accounts cost nothing per account." So this is a third, separate count rather than a change to either — the cap must keep counting suspended seats. An unactivated seat is deliberately still counted: it holds a temporary password, not a suspension, and it is paid for. That is why the filter is narrower than `listDirectory`'s, which also excludes `requires_password_change`. Falsified: dropping the suspended clause fails exactly one test ("drops a suspended seat from the active count but not from the cap", expected 3 to be 2) and leaves the other 34 green. --- src/backend/stores/team/TeamStore.test.ts | 69 +++++++++++++++++++++++ src/backend/stores/team/TeamStore.ts | 19 +++++++ 2 files changed, 88 insertions(+) diff --git a/src/backend/stores/team/TeamStore.test.ts b/src/backend/stores/team/TeamStore.test.ts index f970028e2..fb8284306 100644 --- a/src/backend/stores/team/TeamStore.test.ts +++ b/src/backend/stores/team/TeamStore.test.ts @@ -452,6 +452,75 @@ describe('TeamStore', () => { ).resolves.toBe(false); }); + // -- seat counts -------------------------------------------------- + + // `countSeats` bounds the cap and `countActiveSeats` is the billable + // count, so the two must disagree exactly when a seat is suspended. + describe('counting seats', () => { + const suspend = async (userId: number) => { + await server.clients.db.write( + 'UPDATE `user` SET `suspended` = 1 WHERE `id` = ?', + [userId], + ); + }; + + const seatedTeam = async (seats = 0) => { + const team = await store.create({ + ownerUserId: owner.id, + name: 'Counted', + handle: freeHandle(), + }); + const members: { id: number }[] = []; + for (let i = 0; i < seats; i++) { + const m = await makeUser(); + await store.addMember(team.uid, m.id, { orgOwned: true }); + members.push(m); + } + return { team, members }; + }; + + it('counts provisioned seats and active seats alike when none is suspended', async () => { + const { team } = await seatedTeam(3); + expect(await store.countSeats(team.id)).toBe(3); + expect(await store.countActiveSeats(team.id)).toBe(3); + }); + + it('drops a suspended seat from the active count but not from the cap', async () => { + // The console promises a suspended account costs nothing per + // account, while the seat it holds is still taken. + const { team, members } = await seatedTeam(3); + await suspend(members[0].id); + + expect(await store.countActiveSeats(team.id)).toBe(2); + expect(await store.countSeats(team.id)).toBe(3); + }); + + it('still counts a provisioned seat that has never been activated', async () => { + // It holds a temporary password, not a suspension — it is paid for. + const { team, members } = await seatedTeam(1); + await server.clients.db.write( + 'UPDATE `user` SET `requires_password_change` = 1 WHERE `id` = ?', + [members[0].id], + ); + + expect(await store.countActiveSeats(team.id)).toBe(1); + }); + + it('excludes the payer, who is not a seat', async () => { + const { team } = await seatedTeam(2); + const payer = await makeUser(); + await server.stores.user.update(payer.id, { password: 'hashed' }); + await store.addMember(team.uid, payer.id, { orgOwned: false }); + + expect(await store.countActiveSeats(team.id)).toBe(2); + }); + + it('is zero for a team with no seats, not an error', async () => { + const { team } = await seatedTeam(0); + expect(await store.countActiveSeats(team.id)).toBe(0); + }); + }); + // -- pagination --------------------------------------------------- it('pages members on `id` and stops when the set is exhausted', async () => { diff --git a/src/backend/stores/team/TeamStore.ts b/src/backend/stores/team/TeamStore.ts index 32bd7d208..661ff2550 100644 --- a/src/backend/stores/team/TeamStore.ts +++ b/src/backend/stores/team/TeamStore.ts @@ -577,6 +577,25 @@ export class TeamStore extends PuterStore { return Number(rows[0]?.n ?? 0); } + /** + * Seats that are provisioned and not suspended. + * + * Distinct from `countSeats` on purpose. That one bounds the cap, where a + * suspended seat still counts because it still holds a username. This one + * answers "how many accounts is the team getting the benefit of", which is + * what the console tells the owner they pay per account for. + */ + async countActiveSeats(teamId: number): Promise { + const rows = (await this.clients.db.read( + 'SELECT COUNT(*) AS n FROM `jct_user_group` ug ' + + 'JOIN `user` u ON u.`id` = ug.`user_id` ' + + 'WHERE ug.`group_id` = ? AND ug.`org_owned` = 1 ' + + 'AND (u.`suspended` IS NULL OR u.`suspended` = 0)', + [teamId], + )) as { n: number }[]; + return Number(rows[0]?.n ?? 0); + } + /** Live teams this user owns. Soft-deleted ones do not count. */ async countOwned(ownerUserId: number): Promise { const rows = (await this.clients.db.read(