feat: set a plan per account, not one tier for the whole team

Reverses PUT-1788 D1 at the UI. A team keeps one subscription; each account sits
on its own tier within it.

The plan card stops being the chooser and becomes a summary: what each tier
costs per account, and how many accounts are on it. Choosing happens on the
account, with a Change plan action per row, because that is the thing the tier
now belongs to.

`memberPlanLabel` reads the seat's own assignment rather than the team's single
tier, so two accounts can honestly show different plans. The other three states
are unchanged and still matter: the owner is the payer, a suspended seat is not
billed whatever it was on, and a seat nobody bought a tier for is free.

The action only appears where a billing extension is present and a catalogue
came back, so a deployment that sells nothing shows prices and no dead buttons.
This commit is contained in:
Juan Castro
2026-09-11 10:04:40 -04:00
parent f2c9737bd8
commit 187e4c8a2f
6 changed files with 140 additions and 118 deletions
+44 -10
View File
@@ -89,6 +89,9 @@ const renderMemberRow = (member) => {
h += `<td>${planCell(member)}</td>`;
h += '<td class="teams-member-actions">';
if ( member.orgOwned ) {
if ( window.team_billing_ui && state.plan?.status === 'ready' ) {
h += `<button class="button button-small teams-plan-change" data-username="${username}" data-uuid="${html_encode(member.uuid ?? '')}">${i18n('teams_plan_change')}</button>`;
}
h += `<button class="button button-small teams-reset" data-username="${username}">${i18n('teams_reissue_credential')}</button>`;
h += member.disabled
? `<button class="button button-small teams-enable" data-username="${username}">${i18n('teams_enable_account')}</button>`
@@ -186,9 +189,6 @@ const renderMemberView = () => {
const renderPlan = () => teamPlanHtml({
plan: state.plan,
// The same count the accounts table shows: suspended seats are excluded,
// because they stop costing a per-account charge.
seats: membersBillingSummary(annotateMembers(state.members, state.audit)).billed,
canBuy: window.team_billing_ui === true,
});
@@ -280,21 +280,52 @@ const loadPlan = async (teamUid) => {
get('/marketplace/subscriptions/team-offerings'),
get(`/marketplace/teams/${encodeURIComponent(teamUid)}/subscription`),
]);
if ( ! cat ) return { status: 'unavailable', offerings: [], current: null };
const offerings = Array.isArray(cat.offerings) ? cat.offerings : [];
if ( ! cat ) return { status: 'unavailable', offerings: [], seatTiers: {} };
const entry = sub?.subscription ?? null;
return {
status: 'ready',
offerings,
current: entry
? { ...entry, name_en: offerings.find(o => o.tier === entry.tier)?.name_en }
: null,
offerings: Array.isArray(cat.offerings) ? cat.offerings : [],
seatTiers: entry?.seatTiers ?? {},
tierQuantities: entry?.tierQuantities ?? {},
subStatus: entry?.status ?? null,
};
} catch {
return { status: 'unavailable', offerings: [], current: null };
return { status: 'unavailable', offerings: [], seatTiers: {} };
}
};
/** Asks which tier, then hands the purchase to the billing extension. */
const changeSeatPlan = async ($el_window, username, uuid) => {
const plan = state.plan;
if ( ! plan || plan.status !== 'ready' ) return;
const current = plan.seatTiers?.[uuid] ?? null;
const choices = plan.offerings.filter(o => o.available && o.tier !== current);
if ( choices.length === 0 ) return;
const answer = await UIAlert({
type: 'confirm',
message: i18n('teams_plan_change_for', { username }),
buttons: [
...choices.map(o => ({
label: `${o.name_en || o.tier}${o.amountPerSeat} ${o.currency}`,
value: o.itemId,
})),
{ label: i18n('cancel'), value: 'no' },
],
...modalOptions($el_window),
});
if ( ! answer || answer === 'no' ) return;
window.dispatchEvent(new CustomEvent('team-plan-purchase', {
detail: {
teamUid: state.selected.uid,
itemId: answer,
seatUuid: uuid,
onDone: () => refresh($el_window),
},
}));
};
const load = async ($el_window) => {
// The API can be on while the interface is not; same effect as no route.
if ( ! window.teams_ui ) {
@@ -530,6 +561,9 @@ const TabTeams = {
$el_window.on('click', `${SECTION} .teams-rename`, () => renameTeam($el_window));
$el_window.on('click', `${SECTION} .teams-delete`, () => deleteTeam($el_window));
// Checkout is the billing extension's job; this only says what was asked for.
$el_window.on('click', `${SECTION} .teams-plan-change`, function () {
changeSeatPlan($el_window, $(this).attr('data-username'), $(this).attr('data-uuid'));
});
$el_window.on('click', `${SECTION} .teams-plan-buy`, function () {
if ( ! state.selected ) return;
window.dispatchEvent(new CustomEvent('team-plan-purchase', {
+27 -34
View File
@@ -24,48 +24,41 @@
* @param {object} args `{ plan, seats, canBuy }`
* @returns {string} markup, or '' when there is no catalogue
*/
export const teamPlanHtml = ({ plan, seats = 0, canBuy = false } = {}) => {
export const teamPlanHtml = ({ plan, canBuy = false } = {}) => {
if ( plan?.status !== 'ready' ) return '';
const current = plan.current ?? null;
const offerings = Array.isArray(plan.offerings) ? plan.offerings : [];
const quantities = plan.tierQuantities ?? {};
const onSomething = Object.values(quantities).some(n => n > 0);
let h = '<div class="dashboard-card teams-panel teams-plan">';
h += `<h3>${i18n('teams_plan')}</h3>`;
if ( current ) {
const key = seats === 1 ? 'teams_plan_current_one' : 'teams_plan_current';
h += `<p class="teams-panel-hint">${i18n(key, {
plan: current.name_en || current.tier,
seats,
})}</p>`;
if ( current.status && current.status !== 'active' ) {
h += `<p class="teams-plan-status">${window.html_encode(current.status)}</p>`;
}
} else {
h += `<p class="teams-panel-hint">${i18n('teams_plan_none')}</p>`;
h += `<p class="teams-panel-hint">${i18n(
onSomething ? 'teams_plan_per_account_hint' : 'teams_plan_none',
)}</p>`;
if ( plan.subStatus && plan.subStatus !== 'active' ) {
h += `<p class="teams-plan-status">${window.html_encode(plan.subStatus)}</p>`;
}
if ( offerings.length ) {
h += '<ul class="teams-plan-list">';
for ( const o of offerings ) {
h += '<li class="teams-plan-option">';
h += `<span class="teams-plan-name">${window.html_encode(o.name_en || o.tier)}</span>`;
h += `<span class="teams-plan-price">${i18n('teams_plan_per_seat', {
amount: o.amountPerSeat,
currency: o.currency,
})}</span>`;
if ( current?.tier === o.tier ) {
h += `<span class="teams-plan-badge">${i18n('teams_plan_current_badge')}</span>`;
} else if ( ! o.available ) {
// The server says no price is configured; buying would 422.
h += `<span class="teams-plan-badge">${i18n('teams_plan_unavailable')}</span>`;
} else if ( canBuy ) {
const label = current ? i18n('teams_plan_switch') : i18n('teams_plan_buy');
h += `<button class="button teams-plan-buy" data-item-id="${window.html_encode(o.itemId)}">${label}</button>`;
}
h += '</li>';
h += '<ul class="teams-plan-list">';
for ( const o of offerings ) {
const count = quantities[o.tier] ?? 0;
h += '<li class="teams-plan-option">';
h += `<span class="teams-plan-name">${window.html_encode(o.name_en || o.tier)}</span>`;
h += `<span class="teams-plan-price">${i18n('teams_plan_per_seat', {
amount: o.amountPerSeat,
currency: o.currency,
})}</span>`;
if ( count > 0 ) {
h += `<span class="teams-plan-badge">${i18n('teams_plan_on_count', { count })}</span>`;
} else if ( ! o.available ) {
// The server says no price is configured; buying would 422.
h += `<span class="teams-plan-badge">${i18n('teams_plan_unavailable')}</span>`;
}
h += '</ul>';
h += '</li>';
}
h += '</ul>';
if ( canBuy ) {
h += `<p class="teams-panel-hint">${i18n('teams_plan_assign_hint')}</p>`;
}
h += '</div>';
return h;
+29 -58
View File
@@ -18,92 +18,63 @@ const offering = (over = {}) => ({
...over,
});
const ready = (over = {}) => ({
status: 'ready',
offerings: [offering()],
current: null,
...over,
});
describe('the team plan card', () => {
const ready = (over = {}) => ({
status: 'ready',
offerings: [offering()],
tierQuantities: {},
...over,
});
it('draws nothing when no catalogue came back', () => {
// A deployment that sells nothing serves no catalogue.
for (const plan of [null, undefined, { status: 'unavailable' }, {}]) {
expect(teamPlanHtml({ plan })).toBe('');
}
});
it('says the team is on the free plan when it has bought nothing', () => {
const h = teamPlanHtml({ plan: ready(), canBuy: true });
expect(h).toContain('teams_plan_none');
});
it('names the current plan and the accounts billed', () => {
it('says plans are per account once something is bought', () => {
const h = teamPlanHtml({
plan: ready({ current: { tier: 'team-basic', name_en: 'Team Basic', status: 'active' } }),
seats: 3,
plan: ready({ tierQuantities: { 'team-basic': 2 } }),
});
expect(h).toContain('plan=Team Basic');
expect(h).toContain('seats=3');
expect(h).toContain('teams_plan_per_account_hint');
});
it('uses the singular for one account', () => {
it('says nothing is bought when no tier has a seat', () => {
expect(teamPlanHtml({ plan: ready() })).toContain('teams_plan_none');
});
it('shows how many accounts are on each tier', () => {
const h = teamPlanHtml({
plan: ready({ current: { tier: 'team-basic', status: 'active' } }),
seats: 1,
plan: ready({ tierQuantities: { 'team-basic': 3 } }),
});
expect(h).toContain('teams_plan_current_one');
expect(h).not.toContain('teams_plan_current(');
expect(h).toContain('count=3');
});
it('surfaces a status that is not active, so dunning is visible', () => {
const h = teamPlanHtml({
plan: ready({ current: { tier: 'team-basic', status: 'past_due' } }),
});
expect(h).toContain('past_due');
});
it('offers no button for a tier with no configured price', () => {
// Buying one 422s, so the button must not be there to press.
it('marks a tier with no configured price unavailable', () => {
// Buying one 422s, so it must not look purchasable.
const h = teamPlanHtml({
plan: ready({ offerings: [offering({ available: false })] }),
canBuy: true,
});
expect(h).toContain('teams_plan_unavailable');
expect(h).not.toContain('teams-plan-buy');
});
it('offers no button without a billing extension to act on it', () => {
it('offers no assignment hint without a billing extension', () => {
const h = teamPlanHtml({ plan: ready(), canBuy: false });
expect(h).not.toContain('teams-plan-buy');
// The prices still render; only the action is missing.
expect(h).not.toContain('teams_plan_assign_hint');
// Prices still render; only the action is missing.
expect(h).toContain('Team Basic');
});
it('marks the tier the team already holds instead of offering it again', () => {
const h = teamPlanHtml({
plan: ready({ current: { tier: 'team-basic', status: 'active' } }),
canBuy: true,
});
expect(h).toContain('teams_plan_current_badge');
it('points at the table when a billing extension is present', () => {
// The card is a summary now; the chooser is per row.
const h = teamPlanHtml({ plan: ready(), canBuy: true });
expect(h).toContain('teams_plan_assign_hint');
expect(h).not.toContain('teams-plan-buy');
});
it('carries the item id the purchase needs', () => {
const h = teamPlanHtml({ plan: ready(), canBuy: true });
expect(h).toContain('data-item-id="puter-team-basic"');
expect(h).toContain('teams_plan_buy');
});
it('says switch, not choose, once a plan is held', () => {
const h = teamPlanHtml({
plan: ready({
offerings: [offering(), offering({ itemId: 'puter-team-pro', tier: 'team-pro', name_en: 'Team Pro' })],
current: { tier: 'team-basic', status: 'active' },
}),
canBuy: true,
});
expect(h).toContain('teams_plan_switch');
it('surfaces a status that is not active, so dunning is visible', () => {
const h = teamPlanHtml({ plan: ready({ subStatus: 'past_due' }) });
expect(h).toContain('past_due');
});
it('encodes a name the server supplied', () => {
+5 -3
View File
@@ -130,9 +130,11 @@ export function membersBillingSummary (annotated) {
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 };
// Per seat: a team can buy for some accounts and not others.
const tier = plan?.seatTiers?.[member.uuid];
if ( ! tier ) return { kind: 'free' };
const offering = (plan.offerings ?? []).find(o => o.tier === tier);
return { kind: 'tier', name: offering?.name_en || tier };
}
/**
+30 -13
View File
@@ -168,32 +168,49 @@ describe('sortMembers', () => {
});
describe('what plan a row in the accounts table shows', () => {
const paid = { current: { tier: 'team-basic', name_en: 'Team Basic' } };
const plan = {
seatTiers: { 'u-1': 'team-basic', 'u-2': 'team-pro' },
offerings: [
{ tier: 'team-basic', name_en: 'Team Basic' },
{ tier: 'team-pro', name_en: 'Team Pro' },
],
};
it('names the team tier for a billed seat', () => {
expect(memberPlanLabel({ orgOwned: true, disabled: false }, paid))
it('names the tier that seat is on', () => {
expect(memberPlanLabel({ orgOwned: true, uuid: 'u-1' }, plan))
.toEqual({ kind: 'tier', name: 'Team Basic' });
});
it('lets two seats be on different tiers', () => {
// The whole point of per-seat: one team, two plans.
expect(memberPlanLabel({ orgOwned: true, uuid: 'u-2' }, plan))
.toEqual({ kind: 'tier', name: 'Team Pro' });
});
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' });
expect(memberPlanLabel({ orgOwned: true, uuid: 'u-1' },
{ seatTiers: { 'u-1': 'team-basic' }, offerings: [] }))
.toEqual({ kind: 'tier', name: 'team-basic' });
});
it('says free for a seat nobody bought a tier for', () => {
expect(memberPlanLabel({ orgOwned: true, uuid: 'u-9' }, plan))
.toEqual({ kind: 'free' });
});
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' });
expect(memberPlanLabel({ orgOwned: false, uuid: 'u-1' }, plan))
.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))
it('says a suspended seat is not billed, whatever it was on', () => {
expect(memberPlanLabel({ orgOwned: true, uuid: 'u-1', disabled: true }, plan))
.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))
it('says free when nothing is bought at all', () => {
for (const p of [null, undefined, { seatTiers: {} }]) {
expect(memberPlanLabel({ orgOwned: true, uuid: 'u-1' }, p))
.toEqual({ kind: 'free' });
}
});
+5
View File
@@ -543,6 +543,11 @@ 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_plan_change_for: 'Which plan for {{username}}?',
teams_plan_per_account_hint: 'Plans are set per account, in the table below.',
teams_plan_on_count: '{{count}} on this plan',
teams_plan_assign_hint: 'Use Change plan on an account to put it on one of these.',
teams_plan_change: 'Change plan',
teams_member_plan: 'Plan',
teams_member_plan_payer: '— payer',
teams_member_plan_not_billed: 'Not billed',