Compare commits

...
Author SHA1 Message Date
Connor Yoh c076086a1f 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.
2026-09-02 14:55:36 +01:00
Connor Yoh fecf4b2067 fix(checkout): seed capacity to what the installation already needs
Screenshotting the stories showed an installation with 240 users arriving on
the capacity stage at one server: below its own minimum, alert showing, button
disabled. The first thing a paying customer saw was a blocked screen they had
to click their way out of.

Entering the stage now seeds the quantity from the current user count, so that
installation lands on three servers with the total already correct. The alert
and the disabled button stay as a guard for a quantity below the minimum
arriving from anywhere else.

Adds serversForUsers as the inverse of usersForServers so the seed and the
stage's own minimum cannot disagree, with tests including the round trip.
2026-09-02 14:50:16 +01:00
Connor Yoh fe40728f10 fix(checkout): give the capacity stories args
frontend-validation failed on typecheck:proprietary. The stories used `render`
without `args`, and `satisfies Meta<typeof CapacityStage>` requires args to
cover every required prop, so all five failed with TS2322.

Moves the shared args onto the meta and lets each story override, matching
PlanSelectionStage. The interactive render stays, because a stepper nobody can
move is not much of a story - it now seeds from args.serverQuantity rather than
a separate prop.

Also drops the redundant "Proprietary/" title prefix; siblings are filed under
StripeCheckout/.
2026-09-02 14:42:53 +01:00
Connor Yoh b4071b9bc3 feat(checkout): let buyers choose Server capacity
Adds a capacity step between billing period and payment, for the Server tier
only. Enterprise is priced per seat and Free has nothing to size, so both go
straight to payment as before.

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, so a buyer picks "3 servers" and reads
"300 users" and "+300 users" without ever converting anything themselves.

server_quantity now rides createCheckoutSession through to the edge function,
which multiplies it by the block size from the pricing policy when it issues
the licence. Before this the base line item was always quantity 1 and the only
way to buy more capacity was Stripe's own portal after the fact.

Two guards on the stepper. It cannot go below the servers needed for the users
already on the installation, because reducing capacity is a renewal
conversation rather than something checkout does by stranding accounts. And at
five servers, or a thousand users, it offers an enterprise quote beside the
purchase - an option, never a wall: self-serve checkout still completes.

The plan copy already reads "100 users included" on main, so no copy sweep is
needed here.
2026-09-02 14:32:41 +01:00
11 changed files with 437 additions and 3 deletions
@@ -6278,6 +6278,23 @@ upgradeSuccess = "Payment successful! Your subscription has been upgraded. The l
upgradeTitle = "Upgrade to {{planName}}"
yearly = "Yearly"
[payment.capacityStage]
continue = "Continue to payment"
customHint = "Rounded up to the next block of {{users}}."
customLabel = "Number of users"
dueToday = "Due today"
enterpriseQuote = "Get an enterprise quote"
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"
description = "We'll use this to send your license key and receipts."
@@ -18,6 +18,8 @@ import { useLicensePolling } from "@app/components/shared/stripeCheckout/hooks/u
import { useCheckoutSession } from "@app/components/shared/stripeCheckout/hooks/useCheckoutSession";
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 { 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";
@@ -83,6 +85,7 @@ const StripeCheckout: React.FC<StripeCheckoutProps> = ({
checkoutState.setCurrentLicenseKey,
checkoutState.setPollingStatus,
minimumSeats,
checkoutState.serverQuantity,
polling.pollForLicenseKey,
onSuccess,
onError,
@@ -106,9 +109,19 @@ const StripeCheckout: React.FC<StripeCheckoutProps> = ({
}
};
// 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 minimum.
checkoutState.setServerQuantity(blocksForUsers(minimumSeats));
navigation.goToStage("capacity");
return;
}
navigation.goToStage("payment");
};
@@ -234,6 +247,17 @@ const StripeCheckout: React.FC<StripeCheckoutProps> = ({
/>
);
case "capacity":
return (
<CapacityStage
selectedPlan={checkoutState.selectedPlan}
serverQuantity={checkoutState.serverQuantity}
setServerQuantity={checkoutState.setServerQuantity}
currentUsers={minimumSeats}
onContinue={() => navigation.goToStage("payment")}
/>
);
case "payment":
return (
<PaymentStage
@@ -19,6 +19,7 @@ export const useCheckoutSession = (
setCurrentLicenseKey: React.Dispatch<React.SetStateAction<string | null>>,
setPollingStatus: React.Dispatch<React.SetStateAction<PollingStatus>>,
minimumSeats: number,
serverQuantity: number,
pollForLicenseKey: (installId: string) => Promise<void>,
onSuccess?: (sessionId: string) => void,
onError?: (error: string) => void,
@@ -76,6 +77,7 @@ export const useCheckoutSession = (
current_license_key: existingLicenseKey,
requires_seats: selectedPlan.requiresSeats,
seat_count: Math.max(1, Math.min(minimumSeats || 1, 10000)),
server_quantity: Math.max(1, serverQuantity || 1),
email: state.email, // Pass collected email from Stage 1
});
@@ -111,6 +113,7 @@ export const useCheckoutSession = (
state.email,
installationId,
minimumSeats,
serverQuantity,
setState,
setInstallationId,
setCurrentLicenseKey,
@@ -20,6 +20,8 @@ export const useCheckoutState = (planGroup: PlanTierGroup) => {
const [selectedPeriod, setSelectedPeriod] = useState<"monthly" | "yearly">(
planGroup.yearly ? "yearly" : "monthly",
);
// 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>(
null,
@@ -50,6 +52,7 @@ export const useCheckoutState = (planGroup: PlanTierGroup) => {
setCurrentLicenseKey(null);
setLicenseKey(null);
setSelectedPeriod(planGroup.yearly ? "yearly" : "monthly");
setServerQuantity(1);
}, [planGroup]);
return {
@@ -64,6 +67,8 @@ export const useCheckoutState = (planGroup: PlanTierGroup) => {
setEmailError,
selectedPeriod,
setSelectedPeriod,
serverQuantity,
setServerQuantity,
installationId,
setInstallationId,
currentLicenseKey,
@@ -0,0 +1,80 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { useState } from "react";
import { CapacityStage } from "@app/components/shared/stripeCheckout/stages/CapacityStage";
import { PlanTier } from "@app/services/licenseService";
/**
* 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: "Team",
price: 990,
currency: "$",
period: "/year",
features: [],
highlights: [],
lookupKey: "selfhosted:server:yearly",
};
const monthlyPlan: PlanTier = {
...yearlyPlan,
id: "server-monthly",
price: 99,
period: "/month",
};
const meta = {
title: "StripeCheckout/CapacityStage",
component: CapacityStage,
args: {
selectedPlan: yearlyPlan,
serverQuantity: 1,
setServerQuantity: () => {},
onContinue: () => {},
},
// The stepper is the point of this stage, so stories own the quantity and let it move.
render: function Interactive(args) {
const [quantity, setQuantity] = useState(args.serverQuantity);
return (
<CapacityStage
{...args}
serverQuantity={quantity}
setServerQuantity={setQuantity}
/>
);
},
} satisfies Meta<typeof CapacityStage>;
export default meta;
type Story = StoryObj<typeof meta>;
/** A fresh purchase: the smallest block, 100 users. */
export const SingleBlock: 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 cover for fewer than 300. Reducing capacity
* is a renewal conversation, not something checkout does by stranding accounts.
*/
export const ConstrainedByCurrentUsers: Story = {
args: { serverQuantity: 3, currentUsers: 240 },
};
/** Below the minimum the continue button is blocked and the reason is stated. */
export const BelowCurrentUsage: Story = {
args: { serverQuantity: 1, currentUsers: 240 },
};
/**
* 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: () => {} },
};
@@ -0,0 +1,193 @@
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_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 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. */
currentUsers?: number;
onContinue: () => void;
onContactSales?: () => void;
}
/**
* Choose how many users the Team plan should cover.
*
* 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,
serverQuantity,
setServerQuantity,
currentUsers = 0,
onContinue,
onContactSales,
}) => {
const { t } = useTranslation();
const currency = selectedPlan?.currency || "$";
const blockPrice = selectedPlan?.price || 0;
const isYearly = selectedPlan?.period?.includes("year") ?? false;
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.
const minBlocks = blocksForUsers(currentUsers);
const minUsers = usersForBlocks(minBlocks);
const maxUsers = usersForBlocks(SELF_SERVE_MAX_BLOCKS);
const belowCurrentUsage = serverQuantity < minBlocks;
const offerEnterprise = shouldOfferEnterprise(serverQuantity);
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" }}>
<Text size="sm" c="dimmed">
{t(
"payment.capacityStage.subheading",
"Covers everyone you invite, in blocks of {{users}} users.",
{ users: USERS_PER_BLOCK },
)}
</Text>
<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.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}
style={{ width: 220 }}
/>
)}
{belowCurrentUsage && (
<Alert color="yellow" variant="light">
{t(
"payment.capacityStage.minimumForCurrentUsers",
"You have {{users}} users, so the plan must cover at least {{minimum}}.",
{ users: currentUsers, minimum: minUsers },
)}
</Alert>
)}
<Divider />
<Stack gap="xs">
<Group justify="space-between">
<Text size="sm" c="dimmed">
{t(
"payment.capacityStage.lineItem",
"Team · {{price}}{{period}} per {{block}} users",
{
price: formatPrice(blockPrice, currency, 0),
period,
block: USERS_PER_BLOCK,
},
)}
</Text>
<Text size="sm" fw={500}>
{t("payment.capacityStage.userTotal", "{{users}} users", {
users: covered,
})}
</Text>
</Group>
<Group justify="space-between" align="baseline">
<Text fw={600}>
{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">
<Button onClick={onContinue} disabled={belowCurrentUsage} fullWidth>
{t("payment.capacityStage.continue", "Continue to payment")}
</Button>
{offerEnterprise && onContactSales && (
<Button variant="secondary" onClick={onContactSales} fullWidth>
{t(
"payment.capacityStage.enterpriseQuote",
"Get an enterprise quote",
)}
</Button>
)}
</Stack>
</Stack>
);
};
@@ -22,6 +22,7 @@ export interface StripeCheckoutProps {
export type CheckoutStage =
| "email"
| "plan-selection"
| "capacity"
| "payment"
| "success"
| "error";
@@ -0,0 +1,60 @@
import { describe, expect, it } from "vitest";
import {
USERS_PER_BLOCK,
SELF_SERVE_MAX_BLOCKS,
ENTERPRISE_ADVISORY_USERS,
usersForBlocks,
blocksForUsers,
shouldOfferEnterprise,
} from "@app/components/shared/stripeCheckout/utils/capacity";
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 block", () => {
// Zero would read as "no capacity" and price the plan at nothing.
expect(usersForBlocks(0)).toBe(USERS_PER_BLOCK);
expect(usersForBlocks(-2)).toBe(USERS_PER_BLOCK);
});
});
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 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 usersForBlocks", () => {
expect(blocksForUsers(usersForBlocks(4))).toBe(4);
});
});
describe("shouldOfferEnterprise", () => {
it("stays quiet for a small purchase", () => {
expect(shouldOfferEnterprise(1)).toBe(false);
expect(shouldOfferEnterprise(2)).toBe(false);
});
it("offers the quote once the purchase reaches the self-serve maximum", () => {
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_BLOCK);
expect(usersForBlocks(servers)).toBeGreaterThanOrEqual(
ENTERPRISE_ADVISORY_USERS,
);
expect(shouldOfferEnterprise(servers)).toBe(true);
});
});
@@ -0,0 +1,45 @@
/**
* 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
* it from, so the checkout needs a display default. Keep it in step with
* `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_BLOCK = 100;
/** Blocks a buyer can put through self-serve checkout in one go. */
export const SELF_SERVE_MAX_BLOCKS = 5;
/** Capacity at which an enterprise quote is also worth offering. */
export const ENTERPRISE_ADVISORY_USERS = 1000;
/** 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;
}
/** 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(blocks: number): boolean {
return (
blocks >= SELF_SERVE_MAX_BLOCKS ||
usersForBlocks(blocks) >= ENTERPRISE_ADVISORY_USERS
);
}
@@ -36,6 +36,10 @@ export const getModalTitle = (
"Select Billing Period - {{planName}}",
{ planName },
);
case "capacity":
return t("payment.capacityStage.modalTitle", "Upgrade to {{planName}}", {
planName,
});
case "payment":
return t(
"payment.paymentStage.modalTitle",
@@ -42,6 +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; // Blocks of users to buy; the Stripe line item counts these
email?: string; // Customer email for checkout pre-fill
successUrl?: string;
cancelUrl?: string;
@@ -186,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",
@@ -197,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",
@@ -297,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,
@@ -352,6 +353,7 @@ const licenseService = {
current_license_key: request.current_license_key,
requires_seats: request.requires_seats,
seat_count: request.seat_count || 1,
server_quantity: request.server_quantity || 1,
email: request.email,
callback_base_url: baseUrl,
ui_mode: checkoutMode,