mirror of
https://github.com/HeyPuter/puter.git
synced 2026-09-21 12:46:00 +00:00
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:
@@ -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);
|
||||
|
||||
@@ -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' });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user