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".
This commit is contained in:
Juan Castro
2026-09-11 09:37:34 -04:00
parent 2083ef47c0
commit f2c9737bd8
4 changed files with 65 additions and 0 deletions
+9
View File
@@ -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 = `<tr class="teams-member-row${member.disabled ? ' teams-member-disabled' : ''}" data-username="${username}">`;
@@ -79,6 +86,7 @@ const renderMemberRow = (member) => {
h += `<td>${i18n(member.orgOwned ? 'teams_member_provisioned' : 'teams_member_joined')}</td>`;
h += `<td>${i18n(member.disabled ? 'teams_member_state_disabled' : 'teams_member_state_active')}</td>`;
h += `<td>${html_encode(dateText(member.createdAt))}</td>`;
h += `<td>${planCell(member)}</td>`;
h += '<td class="teams-member-actions">';
if ( member.orgOwned ) {
h += `<button class="button button-small teams-reset" data-username="${username}">${i18n('teams_reissue_credential')}</button>`;
@@ -110,6 +118,7 @@ const renderMembers = () => {
h += `<th>${i18n('teams_member_kind')}</th>`;
h += `<th>${i18n('teams_member_state')}</th>`;
h += `<th>${i18n('teams_member_since')}</th>`;
h += `<th>${i18n('teams_member_plan')}</th>`;
h += `<th>${i18n('teams_member_actions')}</th>`;
h += '</tr></thead><tbody>';
for ( const member of sortMembers(annotated) ) h += renderMemberRow(member);
+19
View File
@@ -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.
@@ -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' });
}
});
});
+4
View File
@@ -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',