diff --git a/frontend/editor/public/locales/en-US/translation.toml b/frontend/editor/public/locales/en-US/translation.toml index d24860a8ef..4d8018ee1a 100644 --- a/frontend/editor/public/locales/en-US/translation.toml +++ b/frontend/editor/public/locales/en-US/translation.toml @@ -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" diff --git a/frontend/editor/src/proprietary/components/shared/stripeCheckout/StripeCheckout.tsx b/frontend/editor/src/proprietary/components/shared/stripeCheckout/StripeCheckout.tsx index 6cf6818b7e..47589389e8 100644 --- a/frontend/editor/src/proprietary/components/shared/stripeCheckout/StripeCheckout.tsx +++ b/frontend/editor/src/proprietary/components/shared/stripeCheckout/StripeCheckout.tsx @@ -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 = ({ } }; - // 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; } diff --git a/frontend/editor/src/proprietary/components/shared/stripeCheckout/hooks/useCheckoutState.ts b/frontend/editor/src/proprietary/components/shared/stripeCheckout/hooks/useCheckoutState.ts index 0a5dcff584..3529566edb 100644 --- a/frontend/editor/src/proprietary/components/shared/stripeCheckout/hooks/useCheckoutState.ts +++ b/frontend/editor/src/proprietary/components/shared/stripeCheckout/hooks/useCheckoutState.ts @@ -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(1); const [installationId, setInstallationId] = useState(null); const [currentLicenseKey, setCurrentLicenseKey] = useState( diff --git a/frontend/editor/src/proprietary/components/shared/stripeCheckout/stages/CapacityStage.stories.tsx b/frontend/editor/src/proprietary/components/shared/stripeCheckout/stages/CapacityStage.stories.tsx index 69b3cb3dd2..5e4177a418 100644 --- a/frontend/editor/src/proprietary/components/shared/stripeCheckout/stages/CapacityStage.stories.tsx +++ b/frontend/editor/src/proprietary/components/shared/stripeCheckout/stages/CapacityStage.stories.tsx @@ -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; -/** 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: () => {} }, diff --git a/frontend/editor/src/proprietary/components/shared/stripeCheckout/stages/CapacityStage.tsx b/frontend/editor/src/proprietary/components/shared/stripeCheckout/stages/CapacityStage.tsx index bfed8470d4..4cd131b3f9 100644 --- a/frontend/editor/src/proprietary/components/shared/stripeCheckout/stages/CapacityStage.tsx +++ b/frontend/editor/src/proprietary/components/shared/stripeCheckout/stages/CapacityStage.tsx @@ -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 = ({ selectedPlan, @@ -40,69 +46,92 @@ export const CapacityStage: React.FC = ({ 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 ( -
- - {t("payment.capacityStage.heading", "How many users do you need?")} - - - {t( - "payment.capacityStage.subheading", - "Each server covers {{users}} users. Add servers to cover more.", - { users: USERS_PER_SERVER }, - )} - -
+ + {t( + "payment.capacityStage.subheading", + "Covers everyone you invite, in blocks of {{users}} users.", + { users: USERS_PER_BLOCK }, + )} + - + + + {t("payment.capacityStage.usersLabel", "Users")} + + {presets.map((users) => ( + + ))} + + + + {showCustom && ( 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 }} /> - - - {t("payment.capacityStage.coversUpTo", "Covers up to")} - - - {t("payment.capacityStage.userTotal", "{{users}} users", { - users: covered, - })} - - - + )} {belowCurrentUsage && ( {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 }, )} )} @@ -112,29 +141,37 @@ export const CapacityStage: React.FC = ({ - {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, + }, + )} - - {t("payment.capacityStage.addedUsers", "+{{users}} users", { + + {t("payment.capacityStage.userTotal", "{{users}} users", { users: covered, })} - {isYearly - ? t("payment.capacityStage.totalYearly", "Total, billed yearly") - : t( - "payment.capacityStage.totalMonthly", - "Total, billed monthly", - )} + {t("payment.capacityStage.dueToday", "Due today")} {formatPrice(total, currency)} + + {t( + "payment.capacityStage.renewalNote", + "Renews at {{total}}{{period}}. Cancel any time in Usage & Billing.", + { total: formatPrice(total, currency, 0), period }, + )} + diff --git a/frontend/editor/src/proprietary/components/shared/stripeCheckout/utils/capacity.test.ts b/frontend/editor/src/proprietary/components/shared/stripeCheckout/utils/capacity.test.ts index b7b94b9fe0..87c9e66a63 100644 --- a/frontend/editor/src/proprietary/components/shared/stripeCheckout/utils/capacity.test.ts +++ b/frontend/editor/src/proprietary/components/shared/stripeCheckout/utils/capacity.test.ts @@ -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); diff --git a/frontend/editor/src/proprietary/components/shared/stripeCheckout/utils/capacity.ts b/frontend/editor/src/proprietary/components/shared/stripeCheckout/utils/capacity.ts index b96b4dba22..9af68bd64b 100644 --- a/frontend/editor/src/proprietary/components/shared/stripeCheckout/utils/capacity.ts +++ b/frontend/editor/src/proprietary/components/shared/stripeCheckout/utils/capacity.ts @@ -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 ); } diff --git a/frontend/editor/src/proprietary/components/shared/stripeCheckout/utils/checkoutUtils.ts b/frontend/editor/src/proprietary/components/shared/stripeCheckout/utils/checkoutUtils.ts index 99ebc01bc6..6b021ff962 100644 --- a/frontend/editor/src/proprietary/components/shared/stripeCheckout/utils/checkoutUtils.ts +++ b/frontend/editor/src/proprietary/components/shared/stripeCheckout/utils/checkoutUtils.ts @@ -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", diff --git a/frontend/editor/src/proprietary/services/licenseService.ts b/frontend/editor/src/proprietary/services/licenseService.ts index dbd6e5c915..63320ba6ba 100644 --- a/frontend/editor/src/proprietary/services/licenseService.ts +++ b/frontend/editor/src/proprietary/services/licenseService.ts @@ -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,