From f2c9737bd85b45aa35f1a2c76b52999204c89691 Mon Sep 17 00:00:00 2001 From: Juan Castro Date: Fri, 11 Sep 2026 09:37:34 -0400 Subject: [PATCH] feat: show each account's plan in the team's accounts table Per-seat billing context without per-seat subscriptions. PUT-1788 D1 stands: one tier per team, one Stripe subscription, quantity = seat count. The tier is still changed once, in the plan card. Four states, because "on Team Basic" is not true of every row. The owner is the payer and keeps their own personal plan, so they show as such rather than inheriting the team's. A suspended seat reads "not billed" -- it stops costing a per-account charge, which is the same rule the billing summary and the plan card's seat count already use. A seat of a team that bought nothing reads free, which is the reduced org-seat allowance. Everyone else shows the tier. The rule is a helper in teamsConsole so it is testable, like the rest of that file. Falsified: dropping the payer branch fails "says the owner is the payer, not a seat"; dropping the suspended branch fails "says a suspended seat is not billed". --- src/gui/src/UI/Dashboard/TabTeams.js | 9 +++++ src/gui/src/UI/Dashboard/teamsConsole.js | 19 +++++++++++ src/gui/src/UI/Dashboard/teamsConsole.test.js | 33 +++++++++++++++++++ src/gui/src/i18n/translations/en.js | 4 +++ 4 files changed, 65 insertions(+) diff --git a/src/gui/src/UI/Dashboard/TabTeams.js b/src/gui/src/UI/Dashboard/TabTeams.js index b1a701f19..576c1c090 100644 --- a/src/gui/src/UI/Dashboard/TabTeams.js +++ b/src/gui/src/UI/Dashboard/TabTeams.js @@ -26,6 +26,7 @@ import { canDeleteAccount, auditReasonKey, membersBillingSummary, + memberPlanLabel, sortMembers, } from './teamsConsole.js'; @@ -72,6 +73,12 @@ const renderTeamPicker = () => { return h; }; +const planCell = (member) => { + const label = memberPlanLabel(member, state.plan); + if ( label.kind === 'tier' ) return html_encode(label.name); + return i18n(`teams_member_plan_${label.kind}`); +}; + const renderMemberRow = (member) => { const username = html_encode(member.username); let h = ``; @@ -79,6 +86,7 @@ const renderMemberRow = (member) => { h += `${i18n(member.orgOwned ? 'teams_member_provisioned' : 'teams_member_joined')}`; h += `${i18n(member.disabled ? 'teams_member_state_disabled' : 'teams_member_state_active')}`; h += `${html_encode(dateText(member.createdAt))}`; + h += `${planCell(member)}`; h += ''; if ( member.orgOwned ) { h += ``; @@ -110,6 +118,7 @@ const renderMembers = () => { h += `${i18n('teams_member_kind')}`; h += `${i18n('teams_member_state')}`; h += `${i18n('teams_member_since')}`; + h += `${i18n('teams_member_plan')}`; h += `${i18n('teams_member_actions')}`; h += ''; for ( const member of sortMembers(annotated) ) h += renderMemberRow(member); diff --git a/src/gui/src/UI/Dashboard/teamsConsole.js b/src/gui/src/UI/Dashboard/teamsConsole.js index c55fc9553..ba91e1d27 100644 --- a/src/gui/src/UI/Dashboard/teamsConsole.js +++ b/src/gui/src/UI/Dashboard/teamsConsole.js @@ -116,6 +116,25 @@ export function membersBillingSummary (annotated) { return summary; } +/** + * What plan a row in the accounts table is on. + * + * `payer` is the owner, who keeps their personal plan. A suspended seat is + * `not_billed` -- it stops costing a per-account charge. Everyone else follows + * the team: its tier if it bought one, otherwise the reduced free allowance. + * + * @param {{ orgOwned: boolean, disabled: boolean }} member + * @param {{ current: { tier: string, name_en?: string } | null } | null} plan + * @returns {{ kind: 'payer'|'not_billed'|'free'|'tier', name?: string }} + */ +export function memberPlanLabel (member, plan) { + if ( ! member?.orgOwned ) return { kind: 'payer' }; + if ( member.disabled ) return { kind: 'not_billed' }; + const current = plan?.current; + if ( ! current ) return { kind: 'free' }; + return { kind: 'tier', name: current.name_en || current.tier }; +} + /** * The i18n key for an audit action, or `null` for one this build does not know * about — a new backend action must show as itself rather than as nothing. diff --git a/src/gui/src/UI/Dashboard/teamsConsole.test.js b/src/gui/src/UI/Dashboard/teamsConsole.test.js index 2e0d830df..a061b5a76 100644 --- a/src/gui/src/UI/Dashboard/teamsConsole.test.js +++ b/src/gui/src/UI/Dashboard/teamsConsole.test.js @@ -6,6 +6,7 @@ import { auditReasonKey, memberStatesFromAudit, membersBillingSummary, + memberPlanLabel, sortMembers, } from './teamsConsole.js'; @@ -165,3 +166,35 @@ describe('sortMembers', () => { expect(annotated.map(m => m.username)).toEqual(['zoe', 'ann']); }); }); + +describe('what plan a row in the accounts table shows', () => { + const paid = { current: { tier: 'team-basic', name_en: 'Team Basic' } }; + + it('names the team tier for a billed seat', () => { + expect(memberPlanLabel({ orgOwned: true, disabled: false }, paid)) + .toEqual({ kind: 'tier', name: 'Team Basic' }); + }); + + it('falls back to the tier id when the catalogue has no name', () => { + expect(memberPlanLabel({ orgOwned: true }, { current: { tier: 'team-pro' } })) + .toEqual({ kind: 'tier', name: 'team-pro' }); + }); + + it('says the owner is the payer, not a seat', () => { + // They keep their own personal plan; the team tier is not theirs. + expect(memberPlanLabel({ orgOwned: false }, paid)).toEqual({ kind: 'payer' }); + }); + + it('says a suspended seat is not billed', () => { + // It stops costing a per-account charge, which is what the card counts. + expect(memberPlanLabel({ orgOwned: true, disabled: true }, paid)) + .toEqual({ kind: 'not_billed' }); + }); + + it('says free when the team bought nothing', () => { + for (const plan of [null, undefined, { current: null }]) { + expect(memberPlanLabel({ orgOwned: true, disabled: false }, plan)) + .toEqual({ kind: 'free' }); + } + }); +}); diff --git a/src/gui/src/i18n/translations/en.js b/src/gui/src/i18n/translations/en.js index 3c8f4e217..e5980070d 100644 --- a/src/gui/src/i18n/translations/en.js +++ b/src/gui/src/i18n/translations/en.js @@ -543,6 +543,10 @@ const en = { teams_add_account_email_hint: 'If you add an address, we email the username and temporary password to it. Otherwise the password below is the only copy.', teams_add_account_hint: 'Puter creates the account and gives you a one-time password to pass on. The username has to be free across all of Puter.', + teams_member_plan: 'Plan', + teams_member_plan_payer: '— payer', + teams_member_plan_not_billed: 'Not billed', + teams_member_plan_free: 'Free', teams_member_kind: 'Kind', teams_member_state: 'State', teams_member_since: 'Added',