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