From b8b0650990d7058b91c5e5137165d1bea0cb6fba Mon Sep 17 00:00:00 2001 From: Juan Castro Date: Fri, 11 Sep 2026 10:11:34 -0400 Subject: [PATCH] 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". --- src/backend/stores/team/TeamStore.test.ts | 33 +++++++++++++++++++++++ src/backend/stores/team/TeamStore.ts | 18 +++++++++++++ 2 files changed, 51 insertions(+) diff --git a/src/backend/stores/team/TeamStore.test.ts b/src/backend/stores/team/TeamStore.test.ts index fb8284306..e61d5c0fe 100644 --- a/src/backend/stores/team/TeamStore.test.ts +++ b/src/backend/stores/team/TeamStore.test.ts @@ -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); diff --git a/src/backend/stores/team/TeamStore.ts b/src/backend/stores/team/TeamStore.ts index de4ee28b4..bcadaa461 100644 --- a/src/backend/stores/team/TeamStore.ts +++ b/src/backend/stores/team/TeamStore.ts @@ -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 { + 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 { const rows = (await this.clients.db.read(