feat: list the uuids of the seats a team is billed for

Per-tier quantities need to know *which* seats are active, not just how many:
the count has to be split by the tier each one is on. `countActiveSeats` answers
the old question and stays for the cap and the console copy.

Keyed by `uid` rather than the numeric id, because the billing side holds uids
and would otherwise have to look the team up twice. Same suspension filter, and
a test asserts the two agree so they cannot drift apart.

Falsified: dropping the suspension clause fails "leaves out a suspended seat,
matching countActiveSeats".
This commit is contained in:
Juan Castro
2026-09-11 10:11:34 -04:00
parent bafe8af71b
commit b8b0650990
2 changed files with 51 additions and 0 deletions
+33
View File
@@ -515,6 +515,39 @@ describe('TeamStore', () => {
expect(await store.countActiveSeats(team.id)).toBe(2);
});
it('lists the uuids of the seats it bills for', async () => {
const { team, members } = await seatedTeam(2);
const uuids = await store.listActiveSeatUuids(team.uid);
const expected = await Promise.all(
members.map(async (m) =>
(await server.stores.user.getById(m.id))!.uuid,
),
);
expect(uuids.sort()).toEqual(expected.sort());
});
it('leaves out a suspended seat, matching countActiveSeats', async () => {
const { team, members } = await seatedTeam(2);
await suspend(members[0].id);
const uuids = await store.listActiveSeatUuids(team.uid);
expect(uuids).toHaveLength(1);
expect(uuids).toHaveLength(
await store.countActiveSeats(team.id),
);
});
it('leaves out the payer, who is not a seat', async () => {
const { team } = await seatedTeam(1);
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.listActiveSeatUuids(team.uid)).toHaveLength(1);
});
it('is empty for an unknown team, not an error', async () => {
expect(await store.listActiveSeatUuids('no-such-team')).toEqual([]);
});
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);
+18
View File
@@ -597,6 +597,24 @@ export class TeamStore extends PuterStore {
return Number(rows[0]?.n ?? 0);
}
/**
* Uuids of the seats a team is actually billed for, keyed by `uid` because
* that is what the billing side holds. Same filter as `countActiveSeats`;
* the quantities are per tier now, so a count is no longer enough.
*/
async listActiveSeatUuids(teamUid: string): Promise<string[]> {
const rows = (await this.clients.db.read(
'SELECT u.`uuid` AS `uuid` FROM `jct_user_group` ug ' +
'JOIN `user` u ON u.`id` = ug.`user_id` ' +
'JOIN `group` g ON g.`id` = ug.`group_id` ' +
`WHERE g.\`uid\` = ? AND g.${this.#live()} ` +
'AND ug.`org_owned` = 1 ' +
'AND (u.`suspended` IS NULL OR u.`suspended` = 0)',
[teamUid, TEAM_KIND],
)) as unknown as { uuid: string }[];
return rows.map((r) => r.uuid).filter(Boolean);
}
/** Live teams this user owns. Soft-deleted ones do not count. */
async countOwned(ownerUserId: number): Promise<number> {
const rows = (await this.clients.db.read(