refactor(checkout): sell Team capacity in users, not servers

Marketing's mockup sells the plan by user count, not by the thing Stripe
happens to bill for. The buyer picks 100 / 200 / 300 / 400 or Other; the line
item prices it "Team - $99/mo per 100 users". Nothing customer-facing says
"server" any more.

That reverses the D291 rule the earlier prototype set, where selling surfaces
spoke servers and measuring surfaces spoke users. Both now speak users, which
is also what the Usage & Billing design does.

Blocks survive only where they have to: server_quantity on the checkout request
is what the subscription line item counts, so it stays in blocks and the stage
translates. "Other" rounds up to the next whole block, because a part block
cannot be bought.

Also renames the tier from Server to Team in licenseService, which supplies the
modal title. Main already says "Team plan" everywhere in copy and ships an
unused [plan.team] key; the tier name was the last place still saying Server.
This commit is contained in:
Connor Yoh
2026-09-02 14:55:36 +01:00
parent fecf4b2067
commit c076086a1f
9 changed files with 184 additions and 142 deletions
@@ -6279,19 +6279,21 @@ upgradeTitle = "Upgrade to {{planName}}"
yearly = "Yearly"
[payment.capacityStage]
addedUsers = "+{{users}} users"
continue = "Continue to payment"
coversUpTo = "Covers up to"
customHint = "Rounded up to the next block of {{users}}."
customLabel = "Number of users"
dueToday = "Due today"
enterpriseQuote = "Get an enterprise quote"
heading = "How many users do you need?"
lineItem = "Servers x {{servers}}"
minimumForCurrentUsers = "You have {{users}} users, so you need at least {{servers}} servers."
modalTitle = "Choose Capacity - {{planName}}"
serversLabel = "Servers"
subheading = "Each server covers {{users}} users. Add servers to cover more."
totalMonthly = "Total, billed monthly"
totalYearly = "Total, billed yearly"
lineItem = "Team · {{price}}{{period}} per {{block}} users"
minimumForCurrentUsers = "You have {{users}} users, so the plan must cover at least {{minimum}}."
modalTitle = "Upgrade to {{planName}}"
other = "Other"
perMonth = "/mo"
perYear = "/yr"
renewalNote = "Renews at {{total}}{{period}}. Cancel any time in Usage & Billing."
subheading = "Covers everyone you invite, in blocks of {{users}} users."
userTotal = "{{users}} users"
usersLabel = "Users"
[payment.emailStage]
continue = "Continue"
@@ -19,7 +19,7 @@ import { useCheckoutSession } from "@app/components/shared/stripeCheckout/hooks/
import { EmailStage } from "@app/components/shared/stripeCheckout/stages/EmailStage";
import { PlanSelectionStage } from "@app/components/shared/stripeCheckout/stages/PlanSelectionStage";
import { CapacityStage } from "@app/components/shared/stripeCheckout/stages/CapacityStage";
import { serversForUsers } from "@app/components/shared/stripeCheckout/utils/capacity";
import { blocksForUsers } from "@app/components/shared/stripeCheckout/utils/capacity";
import { PaymentStage } from "@app/components/shared/stripeCheckout/stages/PaymentStage";
import { SuccessStage } from "@app/components/shared/stripeCheckout/stages/SuccessStage";
import { ErrorStage } from "@app/components/shared/stripeCheckout/stages/ErrorStage";
@@ -109,16 +109,16 @@ const StripeCheckout: React.FC<StripeCheckoutProps> = ({
}
};
// Only the Server tier is sold by the server. Enterprise is priced per seat and free has nothing
// to size, so both go straight to payment.
// Only the Team tier is sold by capacity. Enterprise is priced per seat and free has nothing to
// size, so both go straight to payment.
const sellsCapacity = planGroup.tier === "server";
// Plan selection handler
const handlePlanSelect = (period: "monthly" | "yearly") => {
checkoutState.setSelectedPeriod(period);
if (sellsCapacity) {
// Arrive on the capacity an installation already needs rather than on a blocked "1".
checkoutState.setServerQuantity(serversForUsers(minimumSeats));
// Arrive on the capacity an installation already needs rather than on a blocked minimum.
checkoutState.setServerQuantity(blocksForUsers(minimumSeats));
navigation.goToStage("capacity");
return;
}
@@ -20,7 +20,7 @@ export const useCheckoutState = (planGroup: PlanTierGroup) => {
const [selectedPeriod, setSelectedPeriod] = useState<"monthly" | "yearly">(
planGroup.yearly ? "yearly" : "monthly",
);
// Servers to buy. Only the Server tier asks; every other tier stays at one.
// Blocks of users to buy. Only the Team tier asks; every other tier stays at one.
const [serverQuantity, setServerQuantity] = useState<number>(1);
const [installationId, setInstallationId] = useState<string | null>(null);
const [currentLicenseKey, setCurrentLicenseKey] = useState<string | null>(
@@ -4,12 +4,12 @@ import { CapacityStage } from "@app/components/shared/stripeCheckout/stages/Capa
import { PlanTier } from "@app/services/licenseService";
/**
* The capacity step of the Stripe checkout, between billing period and payment. Only the Server
* tier reaches it: the stepper counts servers, and every figure beside it is stated in users.
* The capacity step of the Stripe checkout, between billing period and payment. Only the Team tier
* reaches it: the buyer picks users, and the plan is priced per block of 100.
*/
const yearlyPlan: PlanTier = {
id: "server-yearly",
name: "Server",
name: "Team",
price: 990,
currency: "$",
period: "/year",
@@ -50,16 +50,16 @@ const meta = {
export default meta;
type Story = StoryObj<typeof meta>;
/** A fresh purchase: one server, 100 users. */
export const SingleServer: Story = {};
/** A fresh purchase: the smallest block, 100 users. */
export const SingleBlock: Story = {};
/** Three servers on the monthly plan, so the total is three times the unit price. */
export const MultipleServersMonthly: Story = {
/** 300 users on the monthly plan, so the total is three block prices. */
export const ThreeBlocksMonthly: Story = {
args: { selectedPlan: monthlyPlan, serverQuantity: 3 },
};
/**
* An installation already running 240 users cannot buy fewer than three servers. Reducing capacity
* An installation already running 240 users cannot buy cover for fewer than 300. Reducing capacity
* is a renewal conversation, not something checkout does by stranding accounts.
*/
export const ConstrainedByCurrentUsers: Story = {
@@ -72,8 +72,8 @@ export const BelowCurrentUsage: Story = {
};
/**
* At five servers the enterprise quote is offered beside the purchase. It is an option, never a
* wall: self-serve checkout still completes.
* At the self-serve maximum the enterprise quote is offered beside the purchase. It is an option,
* never a wall: self-serve checkout still completes.
*/
export const OffersEnterpriseQuote: Story = {
args: { serverQuantity: 5, onContactSales: () => {} },
@@ -1,20 +1,25 @@
import React from "react";
import React, { useState } from "react";
import { Stack, Text, Group, Divider, Alert, NumberInput } from "@mantine/core";
import { Button } from "@app/ui/Button";
import { useTranslation } from "react-i18next";
import { PlanTier } from "@app/services/licenseService";
import { formatPrice } from "@app/components/shared/stripeCheckout/utils/pricingUtils";
import {
USERS_PER_SERVER,
SELF_SERVE_MAX_SERVERS,
usersForServers,
serversForUsers,
USERS_PER_BLOCK,
SELF_SERVE_MAX_BLOCKS,
USER_PRESETS,
usersForBlocks,
blocksForUsers,
shouldOfferEnterprise,
} from "@app/components/shared/stripeCheckout/utils/capacity";
interface CapacityStageProps {
/** The plan the buyer picked a billing period for; supplies unit price and currency. */
/** The plan the buyer picked a billing period for; supplies block price and currency. */
selectedPlan: PlanTier | null;
/**
* Blocks of users being bought. Held in blocks because that is what the Stripe line item counts,
* but nothing shown to the buyer says so.
*/
serverQuantity: number;
setServerQuantity: (quantity: number) => void;
/** Users already on this installation, so capacity cannot be set below what is in use. */
@@ -24,10 +29,11 @@ interface CapacityStageProps {
}
/**
* Pick how much capacity to buy.
* Choose how many users the Team plan should cover.
*
* The stepper counts servers because that is the unit we sell; every figure beside it is stated in
* users because that is the unit an admin measures. The line item does the translation.
* The buyer picks users; the plan is priced per block of {@link USERS_PER_BLOCK}. Presets cover the
* common sizes and "Other" opens a free entry that rounds up to the next whole block, because a
* part-block cannot be bought.
*/
export const CapacityStage: React.FC<CapacityStageProps> = ({
selectedPlan,
@@ -40,69 +46,92 @@ export const CapacityStage: React.FC<CapacityStageProps> = ({
const { t } = useTranslation();
const currency = selectedPlan?.currency || "$";
const unitPrice = selectedPlan?.price || 0;
const blockPrice = selectedPlan?.price || 0;
const isYearly = selectedPlan?.period?.includes("year") ?? false;
const covered = usersForServers(serverQuantity);
const total = unitPrice * serverQuantity;
const covered = usersForBlocks(serverQuantity);
const total = blockPrice * serverQuantity;
// Never sell less capacity than is already in use; reducing capacity happens at renewal rather
// than by stranding accounts that already exist. The stage is entered pre-seeded to this minimum,
// so the guard below only fires if a caller passes something lower.
const minServers = serversForUsers(currentUsers);
const belowCurrentUsage = serverQuantity < minServers;
// than by stranding accounts that already exist. The stage is entered pre-seeded to this minimum.
const minBlocks = blocksForUsers(currentUsers);
const minUsers = usersForBlocks(minBlocks);
const maxUsers = usersForBlocks(SELF_SERVE_MAX_BLOCKS);
const belowCurrentUsage = serverQuantity < minBlocks;
const offerEnterprise = shouldOfferEnterprise(serverQuantity);
const setClamped = (value: number) =>
setServerQuantity(
Math.max(minServers, Math.min(SELF_SERVE_MAX_SERVERS, value)),
const presets = USER_PRESETS.filter((users) => users <= maxUsers);
const [showCustom, setShowCustom] = useState(
() => !presets.includes(usersForBlocks(serverQuantity)),
);
const period = isYearly
? t("payment.capacityStage.perYear", "/yr")
: t("payment.capacityStage.perMonth", "/mo");
const selectUsers = (users: number) =>
setServerQuantity(blocksForUsers(users));
return (
<Stack gap="lg" style={{ padding: "1.5rem 2rem" }}>
<div>
<Text size="xl" fw={600}>
{t("payment.capacityStage.heading", "How many users do you need?")}
</Text>
<Text size="sm" c="dimmed" mt={4}>
<Text size="sm" c="dimmed">
{t(
"payment.capacityStage.subheading",
"Each server covers {{users}} users. Add servers to cover more.",
{ users: USERS_PER_SERVER },
"Covers everyone you invite, in blocks of {{users}} users.",
{ users: USERS_PER_BLOCK },
)}
</Text>
</div>
<Group align="flex-end" gap="xl" wrap="wrap">
<Group gap="sm" wrap="wrap" align="center">
<Text size="sm" fw={500}>
{t("payment.capacityStage.usersLabel", "Users")}
</Text>
{presets.map((users) => (
<Button
key={users}
variant={!showCustom && covered === users ? "primary" : "secondary"}
disabled={users < minUsers}
onClick={() => {
setShowCustom(false);
selectUsers(users);
}}
>
{users}
</Button>
))}
<Button
variant={showCustom ? "primary" : "secondary"}
onClick={() => setShowCustom(true)}
>
{t("payment.capacityStage.other", "Other")}
</Button>
</Group>
{showCustom && (
<NumberInput
label={t("payment.capacityStage.serversLabel", "Servers")}
value={serverQuantity}
onChange={(value) => setClamped(Number(value) || minServers)}
min={minServers}
max={SELF_SERVE_MAX_SERVERS}
label={t("payment.capacityStage.customLabel", "Number of users")}
description={t(
"payment.capacityStage.customHint",
"Rounded up to the next block of {{users}}.",
{ users: USERS_PER_BLOCK },
)}
value={covered}
onChange={(value) => selectUsers(Number(value) || minUsers)}
min={minUsers}
max={maxUsers}
step={USERS_PER_BLOCK}
clampBehavior="strict"
allowDecimal={false}
allowNegative={false}
size="lg"
style={{ width: 140 }}
style={{ width: 220 }}
/>
<Stack gap={0} pb={6}>
<Text size="xs" c="dimmed">
{t("payment.capacityStage.coversUpTo", "Covers up to")}
</Text>
<Text size="lg" fw={600}>
{t("payment.capacityStage.userTotal", "{{users}} users", {
users: covered,
})}
</Text>
</Stack>
</Group>
)}
{belowCurrentUsage && (
<Alert color="yellow" variant="light">
{t(
"payment.capacityStage.minimumForCurrentUsers",
"You have {{users}} users, so you need at least {{servers}} servers.",
{ users: currentUsers, servers: minServers },
"You have {{users}} users, so the plan must cover at least {{minimum}}.",
{ users: currentUsers, minimum: minUsers },
)}
</Alert>
)}
@@ -112,29 +141,37 @@ export const CapacityStage: React.FC<CapacityStageProps> = ({
<Stack gap="xs">
<Group justify="space-between">
<Text size="sm" c="dimmed">
{t("payment.capacityStage.lineItem", "Servers x {{servers}}", {
servers: serverQuantity,
})}
{t(
"payment.capacityStage.lineItem",
"Team · {{price}}{{period}} per {{block}} users",
{
price: formatPrice(blockPrice, currency, 0),
period,
block: USERS_PER_BLOCK,
},
)}
</Text>
<Text size="sm">
{t("payment.capacityStage.addedUsers", "+{{users}} users", {
<Text size="sm" fw={500}>
{t("payment.capacityStage.userTotal", "{{users}} users", {
users: covered,
})}
</Text>
</Group>
<Group justify="space-between" align="baseline">
<Text fw={600}>
{isYearly
? t("payment.capacityStage.totalYearly", "Total, billed yearly")
: t(
"payment.capacityStage.totalMonthly",
"Total, billed monthly",
)}
{t("payment.capacityStage.dueToday", "Due today")}
</Text>
<Text size="xl" fw={700}>
{formatPrice(total, currency)}
</Text>
</Group>
<Text size="xs" c="dimmed">
{t(
"payment.capacityStage.renewalNote",
"Renews at {{total}}{{period}}. Cancel any time in Usage & Billing.",
{ total: formatPrice(total, currency, 0), period },
)}
</Text>
</Stack>
<Stack gap="sm">
@@ -1,42 +1,42 @@
import { describe, expect, it } from "vitest";
import {
USERS_PER_SERVER,
SELF_SERVE_MAX_SERVERS,
USERS_PER_BLOCK,
SELF_SERVE_MAX_BLOCKS,
ENTERPRISE_ADVISORY_USERS,
usersForServers,
serversForUsers,
usersForBlocks,
blocksForUsers,
shouldOfferEnterprise,
} from "@app/components/shared/stripeCheckout/utils/capacity";
describe("usersForServers", () => {
it("multiplies servers by the block size", () => {
expect(usersForServers(1)).toBe(USERS_PER_SERVER);
expect(usersForServers(3)).toBe(USERS_PER_SERVER * 3);
describe("usersForBlocks", () => {
it("multiplies blocks by the block size", () => {
expect(usersForBlocks(1)).toBe(USERS_PER_BLOCK);
expect(usersForBlocks(3)).toBe(USERS_PER_BLOCK * 3);
});
it("treats a missing quantity as one server", () => {
it("treats a missing quantity as one block", () => {
// Zero would read as "no capacity" and price the plan at nothing.
expect(usersForServers(0)).toBe(USERS_PER_SERVER);
expect(usersForServers(-2)).toBe(USERS_PER_SERVER);
expect(usersForBlocks(0)).toBe(USERS_PER_BLOCK);
expect(usersForBlocks(-2)).toBe(USERS_PER_BLOCK);
});
});
describe("serversForUsers", () => {
it("rounds part-full servers up", () => {
expect(serversForUsers(1)).toBe(1);
expect(serversForUsers(USERS_PER_SERVER)).toBe(1);
expect(serversForUsers(USERS_PER_SERVER + 1)).toBe(2);
expect(serversForUsers(240)).toBe(3);
describe("blocksForUsers", () => {
it("rounds a part-full block up", () => {
expect(blocksForUsers(1)).toBe(1);
expect(blocksForUsers(USERS_PER_BLOCK)).toBe(1);
expect(blocksForUsers(USERS_PER_BLOCK + 1)).toBe(2);
expect(blocksForUsers(240)).toBe(3);
});
it("never returns zero servers", () => {
// Checkout seeds the stepper from this, and a zero would render a blocked stage.
expect(serversForUsers(0)).toBe(1);
expect(serversForUsers(-5)).toBe(1);
it("never returns zero blocks", () => {
// Checkout seeds the picker from this, and a zero would render a blocked stage.
expect(blocksForUsers(0)).toBe(1);
expect(blocksForUsers(-5)).toBe(1);
});
it("round-trips with usersForServers", () => {
expect(serversForUsers(usersForServers(4))).toBe(4);
it("round-trips with usersForBlocks", () => {
expect(blocksForUsers(usersForBlocks(4))).toBe(4);
});
});
@@ -47,12 +47,12 @@ describe("shouldOfferEnterprise", () => {
});
it("offers the quote once the purchase reaches the self-serve maximum", () => {
expect(shouldOfferEnterprise(SELF_SERVE_MAX_SERVERS)).toBe(true);
expect(shouldOfferEnterprise(SELF_SERVE_MAX_BLOCKS)).toBe(true);
});
it("offers the quote once the resulting capacity is enterprise-sized", () => {
const servers = Math.ceil(ENTERPRISE_ADVISORY_USERS / USERS_PER_SERVER);
expect(usersForServers(servers)).toBeGreaterThanOrEqual(
const servers = Math.ceil(ENTERPRISE_ADVISORY_USERS / USERS_PER_BLOCK);
expect(usersForBlocks(servers)).toBeGreaterThanOrEqual(
ENTERPRISE_ADVISORY_USERS,
);
expect(shouldOfferEnterprise(servers)).toBe(true);
@@ -1,5 +1,10 @@
/**
* Server-plan capacity: one server grants a block of users.
* Team-plan capacity: the plan is sold in blocks of users.
*
* A block is what Stripe charges for (one unit of `selfhosted:server:*`), but nothing
* customer-facing says so: the buyer picks a number of users and the line item prices it per block.
* Only `server_quantity` on the checkout request speaks in blocks, because that is what the
* subscription line item counts.
*
* The authoritative block size lives on the pricing policy and is resolved server-side when the
* licence is issued, then baked into licence metadata. Before a purchase there is no licence to read
@@ -7,34 +12,34 @@
* `pricing_policy.server_plan_user_block`; after purchase the licence is what counts, and the admin
* surfaces read `userBlockSize` off `/license-info` rather than this constant.
*/
export const USERS_PER_SERVER = 100;
export const USERS_PER_BLOCK = 100;
/**
* Servers a buyer can put through self-serve checkout in one go. Past this the enterprise
* conversation is offered alongside the purchase, never instead of it.
*/
export const SELF_SERVE_MAX_SERVERS = 5;
/** Blocks a buyer can put through self-serve checkout in one go. */
export const SELF_SERVE_MAX_BLOCKS = 5;
/** Resulting capacity at which an enterprise quote is also worth offering. */
/** Capacity at which an enterprise quote is also worth offering. */
export const ENTERPRISE_ADVISORY_USERS = 1000;
/** Servers needed to cover a given number of users. Always at least one. */
export function serversForUsers(users: number): number {
return Math.max(1, Math.ceil(Math.max(0, users) / USERS_PER_SERVER));
/** The user counts offered as one-click presets, before "Other". */
export const USER_PRESETS = [100, 200, 300, 400];
/** Users covered by a given number of blocks. */
export function usersForBlocks(blocks: number): number {
return Math.max(1, blocks) * USERS_PER_BLOCK;
}
/** Users covered by a given number of servers. */
export function usersForServers(servers: number): number {
return Math.max(1, servers) * USERS_PER_SERVER;
/** Blocks needed to cover a given number of users. Always at least one. */
export function blocksForUsers(users: number): number {
return Math.max(1, Math.ceil(Math.max(0, users) / USERS_PER_BLOCK));
}
/**
* Whether to surface the enterprise door beside the purchase. Deliberately an option rather than a
* gate: a buyer past these numbers can still complete self-serve checkout.
*/
export function shouldOfferEnterprise(servers: number): boolean {
export function shouldOfferEnterprise(blocks: number): boolean {
return (
servers >= SELF_SERVE_MAX_SERVERS ||
usersForServers(servers) >= ENTERPRISE_ADVISORY_USERS
blocks >= SELF_SERVE_MAX_BLOCKS ||
usersForBlocks(blocks) >= ENTERPRISE_ADVISORY_USERS
);
}
@@ -37,11 +37,9 @@ export const getModalTitle = (
{ planName },
);
case "capacity":
return t(
"payment.capacityStage.modalTitle",
"Choose Capacity - {{planName}}",
{ planName },
);
return t("payment.capacityStage.modalTitle", "Upgrade to {{planName}}", {
planName,
});
case "payment":
return t(
"payment.paymentStage.modalTitle",
@@ -42,7 +42,7 @@ export interface CheckoutSessionRequest {
current_license_key?: string; // Current license key for upgrades
requires_seats?: boolean; // Whether to add adjustable seat pricing
seat_count?: number; // Initial number of seats for enterprise plans (user can adjust in Stripe UI)
server_quantity?: number; // Servers to buy; each grants a block of users
server_quantity?: number; // Blocks of users to buy; the Stripe line item counts these
email?: string; // Customer email for checkout pre-fill
successUrl?: string;
cancelUrl?: string;
@@ -187,7 +187,7 @@ const licenseService = {
{
id: "selfhosted:server:monthly",
lookupKey: "selfhosted:server:monthly",
name: "Server - Monthly",
name: "Team - Monthly",
price: getPriceInfo("selfhosted:server:monthly"),
currency: currencySymbol,
period: "/month",
@@ -198,7 +198,7 @@ const licenseService = {
{
id: "selfhosted:server:yearly",
lookupKey: "selfhosted:server:yearly",
name: "Server - Yearly",
name: "Team - Yearly",
price: getPriceInfo("selfhosted:server:yearly"),
currency: currencySymbol,
period: "/year",
@@ -298,7 +298,7 @@ const licenseService = {
if (serverMonthly || serverYearly) {
groups.push({
tier: "server",
name: "Server",
name: "Team",
monthly: serverMonthly || null,
yearly: serverYearly || null,
features: (serverMonthly || serverYearly)!.features,