feat: add TeamStore.countActiveSeats for the billable seat count

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.
This commit is contained in:
Juan Castro
2026-09-09 18:13:04 -04:00
parent 6146ab9dd3
commit 7ab48c590e
2 changed files with 88 additions and 0 deletions
+69
View File
@@ -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 () => {
+19
View File
@@ -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<number> {
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<number> {
const rows = (await this.clients.db.read(