diff --git a/src/gui/src/UI/Dashboard/TabTeams.js b/src/gui/src/UI/Dashboard/TabTeams.js index 576c1c090..730ef8862 100644 --- a/src/gui/src/UI/Dashboard/TabTeams.js +++ b/src/gui/src/UI/Dashboard/TabTeams.js @@ -89,6 +89,9 @@ const renderMemberRow = (member) => { h += `${planCell(member)}`; h += ''; if ( member.orgOwned ) { + if ( window.team_billing_ui && state.plan?.status === 'ready' ) { + h += ``; + } h += ``; h += member.disabled ? `` @@ -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', { diff --git a/src/gui/src/UI/Dashboard/teamPlan.js b/src/gui/src/UI/Dashboard/teamPlan.js index d3bc6fa34..ea671aac7 100644 --- a/src/gui/src/UI/Dashboard/teamPlan.js +++ b/src/gui/src/UI/Dashboard/teamPlan.js @@ -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 = '
'; h += `

${i18n('teams_plan')}

`; - - if ( current ) { - const key = seats === 1 ? 'teams_plan_current_one' : 'teams_plan_current'; - h += `

${i18n(key, { - plan: current.name_en || current.tier, - seats, - })}

`; - if ( current.status && current.status !== 'active' ) { - h += `

${window.html_encode(current.status)}

`; - } - } else { - h += `

${i18n('teams_plan_none')}

`; + h += `

${i18n( + onSomething ? 'teams_plan_per_account_hint' : 'teams_plan_none', + )}

`; + if ( plan.subStatus && plan.subStatus !== 'active' ) { + h += `

${window.html_encode(plan.subStatus)}

`; } - if ( offerings.length ) { - h += ''; + if ( canBuy ) { + h += `

${i18n('teams_plan_assign_hint')}

`; } h += '
'; return h; diff --git a/src/gui/src/UI/Dashboard/teamPlan.test.js b/src/gui/src/UI/Dashboard/teamPlan.test.js index 5f00a27e8..2b4fbf14a 100644 --- a/src/gui/src/UI/Dashboard/teamPlan.test.js +++ b/src/gui/src/UI/Dashboard/teamPlan.test.js @@ -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', () => { diff --git a/src/gui/src/UI/Dashboard/teamsConsole.js b/src/gui/src/UI/Dashboard/teamsConsole.js index ba91e1d27..ea3016bc3 100644 --- a/src/gui/src/UI/Dashboard/teamsConsole.js +++ b/src/gui/src/UI/Dashboard/teamsConsole.js @@ -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 }; } /** diff --git a/src/gui/src/UI/Dashboard/teamsConsole.test.js b/src/gui/src/UI/Dashboard/teamsConsole.test.js index a061b5a76..003239db4 100644 --- a/src/gui/src/UI/Dashboard/teamsConsole.test.js +++ b/src/gui/src/UI/Dashboard/teamsConsole.test.js @@ -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' }); } }); diff --git a/src/gui/src/i18n/translations/en.js b/src/gui/src/i18n/translations/en.js index e5980070d..7816d6e16 100644 --- a/src/gui/src/i18n/translations/en.js +++ b/src/gui/src/i18n/translations/en.js @@ -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',