`;
+ if ( plan.subStatus && plan.subStatus !== 'active' ) {
+ h += `
${window.html_encode(plan.subStatus)}
`;
}
- if ( offerings.length ) {
- h += '
';
- for ( const o of offerings ) {
- h += '
';
- h += `${window.html_encode(o.name_en || o.tier)}`;
- h += `${i18n('teams_plan_per_seat', {
- amount: o.amountPerSeat,
- currency: o.currency,
- })}`;
- if ( current?.tier === o.tier ) {
- h += `${i18n('teams_plan_current_badge')}`;
- } else if ( ! o.available ) {
- // The server says no price is configured; buying would 422.
- h += `${i18n('teams_plan_unavailable')}`;
- } else if ( canBuy ) {
- const label = current ? i18n('teams_plan_switch') : i18n('teams_plan_buy');
- h += ``;
- }
- h += '
';
+ h += '
';
+ for ( const o of offerings ) {
+ const count = quantities[o.tier] ?? 0;
+ h += '
';
+ h += `${window.html_encode(o.name_en || o.tier)}`;
+ h += `${i18n('teams_plan_per_seat', {
+ amount: o.amountPerSeat,
+ currency: o.currency,
+ })}`;
+ if ( count > 0 ) {
+ h += `${i18n('teams_plan_on_count', { count })}`;
+ } else if ( ! o.available ) {
+ // The server says no price is configured; buying would 422.
+ h += `${i18n('teams_plan_unavailable')}`;
}
- h += '
';
+ h += '';
+ }
+ 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',