mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-03 05:10:16 +03:00
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:
@@ -6279,19 +6279,21 @@ upgradeTitle = "Upgrade to {{planName}}"
|
|||||||
yearly = "Yearly"
|
yearly = "Yearly"
|
||||||
|
|
||||||
[payment.capacityStage]
|
[payment.capacityStage]
|
||||||
addedUsers = "+{{users}} users"
|
|
||||||
continue = "Continue to payment"
|
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"
|
enterpriseQuote = "Get an enterprise quote"
|
||||||
heading = "How many users do you need?"
|
lineItem = "Team · {{price}}{{period}} per {{block}} users"
|
||||||
lineItem = "Servers x {{servers}}"
|
minimumForCurrentUsers = "You have {{users}} users, so the plan must cover at least {{minimum}}."
|
||||||
minimumForCurrentUsers = "You have {{users}} users, so you need at least {{servers}} servers."
|
modalTitle = "Upgrade to {{planName}}"
|
||||||
modalTitle = "Choose Capacity - {{planName}}"
|
other = "Other"
|
||||||
serversLabel = "Servers"
|
perMonth = "/mo"
|
||||||
subheading = "Each server covers {{users}} users. Add servers to cover more."
|
perYear = "/yr"
|
||||||
totalMonthly = "Total, billed monthly"
|
renewalNote = "Renews at {{total}}{{period}}. Cancel any time in Usage & Billing."
|
||||||
totalYearly = "Total, billed yearly"
|
subheading = "Covers everyone you invite, in blocks of {{users}} users."
|
||||||
userTotal = "{{users}} users"
|
userTotal = "{{users}} users"
|
||||||
|
usersLabel = "Users"
|
||||||
|
|
||||||
[payment.emailStage]
|
[payment.emailStage]
|
||||||
continue = "Continue"
|
continue = "Continue"
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import { useCheckoutSession } from "@app/components/shared/stripeCheckout/hooks/
|
|||||||
import { EmailStage } from "@app/components/shared/stripeCheckout/stages/EmailStage";
|
import { EmailStage } from "@app/components/shared/stripeCheckout/stages/EmailStage";
|
||||||
import { PlanSelectionStage } from "@app/components/shared/stripeCheckout/stages/PlanSelectionStage";
|
import { PlanSelectionStage } from "@app/components/shared/stripeCheckout/stages/PlanSelectionStage";
|
||||||
import { CapacityStage } from "@app/components/shared/stripeCheckout/stages/CapacityStage";
|
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 { PaymentStage } from "@app/components/shared/stripeCheckout/stages/PaymentStage";
|
||||||
import { SuccessStage } from "@app/components/shared/stripeCheckout/stages/SuccessStage";
|
import { SuccessStage } from "@app/components/shared/stripeCheckout/stages/SuccessStage";
|
||||||
import { ErrorStage } from "@app/components/shared/stripeCheckout/stages/ErrorStage";
|
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
|
// Only the Team tier is sold by capacity. Enterprise is priced per seat and free has nothing to
|
||||||
// to size, so both go straight to payment.
|
// size, so both go straight to payment.
|
||||||
const sellsCapacity = planGroup.tier === "server";
|
const sellsCapacity = planGroup.tier === "server";
|
||||||
|
|
||||||
// Plan selection handler
|
// Plan selection handler
|
||||||
const handlePlanSelect = (period: "monthly" | "yearly") => {
|
const handlePlanSelect = (period: "monthly" | "yearly") => {
|
||||||
checkoutState.setSelectedPeriod(period);
|
checkoutState.setSelectedPeriod(period);
|
||||||
if (sellsCapacity) {
|
if (sellsCapacity) {
|
||||||
// Arrive on the capacity an installation already needs rather than on a blocked "1".
|
// Arrive on the capacity an installation already needs rather than on a blocked minimum.
|
||||||
checkoutState.setServerQuantity(serversForUsers(minimumSeats));
|
checkoutState.setServerQuantity(blocksForUsers(minimumSeats));
|
||||||
navigation.goToStage("capacity");
|
navigation.goToStage("capacity");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -20,7 +20,7 @@ export const useCheckoutState = (planGroup: PlanTierGroup) => {
|
|||||||
const [selectedPeriod, setSelectedPeriod] = useState<"monthly" | "yearly">(
|
const [selectedPeriod, setSelectedPeriod] = useState<"monthly" | "yearly">(
|
||||||
planGroup.yearly ? "yearly" : "monthly",
|
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 [serverQuantity, setServerQuantity] = useState<number>(1);
|
||||||
const [installationId, setInstallationId] = useState<string | null>(null);
|
const [installationId, setInstallationId] = useState<string | null>(null);
|
||||||
const [currentLicenseKey, setCurrentLicenseKey] = useState<string | null>(
|
const [currentLicenseKey, setCurrentLicenseKey] = useState<string | null>(
|
||||||
|
|||||||
+10
-10
@@ -4,12 +4,12 @@ import { CapacityStage } from "@app/components/shared/stripeCheckout/stages/Capa
|
|||||||
import { PlanTier } from "@app/services/licenseService";
|
import { PlanTier } from "@app/services/licenseService";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The capacity step of the Stripe checkout, between billing period and payment. Only the Server
|
* The capacity step of the Stripe checkout, between billing period and payment. Only the Team tier
|
||||||
* tier reaches it: the stepper counts servers, and every figure beside it is stated in users.
|
* reaches it: the buyer picks users, and the plan is priced per block of 100.
|
||||||
*/
|
*/
|
||||||
const yearlyPlan: PlanTier = {
|
const yearlyPlan: PlanTier = {
|
||||||
id: "server-yearly",
|
id: "server-yearly",
|
||||||
name: "Server",
|
name: "Team",
|
||||||
price: 990,
|
price: 990,
|
||||||
currency: "$",
|
currency: "$",
|
||||||
period: "/year",
|
period: "/year",
|
||||||
@@ -50,16 +50,16 @@ const meta = {
|
|||||||
export default meta;
|
export default meta;
|
||||||
type Story = StoryObj<typeof meta>;
|
type Story = StoryObj<typeof meta>;
|
||||||
|
|
||||||
/** A fresh purchase: one server, 100 users. */
|
/** A fresh purchase: the smallest block, 100 users. */
|
||||||
export const SingleServer: Story = {};
|
export const SingleBlock: Story = {};
|
||||||
|
|
||||||
/** Three servers on the monthly plan, so the total is three times the unit price. */
|
/** 300 users on the monthly plan, so the total is three block prices. */
|
||||||
export const MultipleServersMonthly: Story = {
|
export const ThreeBlocksMonthly: Story = {
|
||||||
args: { selectedPlan: monthlyPlan, serverQuantity: 3 },
|
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.
|
* is a renewal conversation, not something checkout does by stranding accounts.
|
||||||
*/
|
*/
|
||||||
export const ConstrainedByCurrentUsers: Story = {
|
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
|
* At the self-serve maximum the enterprise quote is offered beside the purchase. It is an option,
|
||||||
* wall: self-serve checkout still completes.
|
* never a wall: self-serve checkout still completes.
|
||||||
*/
|
*/
|
||||||
export const OffersEnterpriseQuote: Story = {
|
export const OffersEnterpriseQuote: Story = {
|
||||||
args: { serverQuantity: 5, onContactSales: () => {} },
|
args: { serverQuantity: 5, onContactSales: () => {} },
|
||||||
|
|||||||
+101
-64
@@ -1,20 +1,25 @@
|
|||||||
import React from "react";
|
import React, { useState } from "react";
|
||||||
import { Stack, Text, Group, Divider, Alert, NumberInput } from "@mantine/core";
|
import { Stack, Text, Group, Divider, Alert, NumberInput } from "@mantine/core";
|
||||||
import { Button } from "@app/ui/Button";
|
import { Button } from "@app/ui/Button";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { PlanTier } from "@app/services/licenseService";
|
import { PlanTier } from "@app/services/licenseService";
|
||||||
import { formatPrice } from "@app/components/shared/stripeCheckout/utils/pricingUtils";
|
import { formatPrice } from "@app/components/shared/stripeCheckout/utils/pricingUtils";
|
||||||
import {
|
import {
|
||||||
USERS_PER_SERVER,
|
USERS_PER_BLOCK,
|
||||||
SELF_SERVE_MAX_SERVERS,
|
SELF_SERVE_MAX_BLOCKS,
|
||||||
usersForServers,
|
USER_PRESETS,
|
||||||
serversForUsers,
|
usersForBlocks,
|
||||||
|
blocksForUsers,
|
||||||
shouldOfferEnterprise,
|
shouldOfferEnterprise,
|
||||||
} from "@app/components/shared/stripeCheckout/utils/capacity";
|
} from "@app/components/shared/stripeCheckout/utils/capacity";
|
||||||
|
|
||||||
interface CapacityStageProps {
|
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;
|
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;
|
serverQuantity: number;
|
||||||
setServerQuantity: (quantity: number) => void;
|
setServerQuantity: (quantity: number) => void;
|
||||||
/** Users already on this installation, so capacity cannot be set below what is in use. */
|
/** 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
|
* The buyer picks users; the plan is priced per block of {@link USERS_PER_BLOCK}. Presets cover the
|
||||||
* users because that is the unit an admin measures. The line item does the translation.
|
* 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> = ({
|
export const CapacityStage: React.FC<CapacityStageProps> = ({
|
||||||
selectedPlan,
|
selectedPlan,
|
||||||
@@ -40,69 +46,92 @@ export const CapacityStage: React.FC<CapacityStageProps> = ({
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const currency = selectedPlan?.currency || "$";
|
const currency = selectedPlan?.currency || "$";
|
||||||
const unitPrice = selectedPlan?.price || 0;
|
const blockPrice = selectedPlan?.price || 0;
|
||||||
const isYearly = selectedPlan?.period?.includes("year") ?? false;
|
const isYearly = selectedPlan?.period?.includes("year") ?? false;
|
||||||
const covered = usersForServers(serverQuantity);
|
const covered = usersForBlocks(serverQuantity);
|
||||||
const total = unitPrice * serverQuantity;
|
const total = blockPrice * serverQuantity;
|
||||||
|
|
||||||
// Never sell less capacity than is already in use; reducing capacity happens at renewal rather
|
// 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,
|
// 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 minBlocks = blocksForUsers(currentUsers);
|
||||||
const minServers = serversForUsers(currentUsers);
|
const minUsers = usersForBlocks(minBlocks);
|
||||||
const belowCurrentUsage = serverQuantity < minServers;
|
const maxUsers = usersForBlocks(SELF_SERVE_MAX_BLOCKS);
|
||||||
|
const belowCurrentUsage = serverQuantity < minBlocks;
|
||||||
const offerEnterprise = shouldOfferEnterprise(serverQuantity);
|
const offerEnterprise = shouldOfferEnterprise(serverQuantity);
|
||||||
|
|
||||||
const setClamped = (value: number) =>
|
const presets = USER_PRESETS.filter((users) => users <= maxUsers);
|
||||||
setServerQuantity(
|
const [showCustom, setShowCustom] = useState(
|
||||||
Math.max(minServers, Math.min(SELF_SERVE_MAX_SERVERS, value)),
|
() => !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 (
|
return (
|
||||||
<Stack gap="lg" style={{ padding: "1.5rem 2rem" }}>
|
<Stack gap="lg" style={{ padding: "1.5rem 2rem" }}>
|
||||||
<div>
|
<Text size="sm" c="dimmed">
|
||||||
<Text size="xl" fw={600}>
|
{t(
|
||||||
{t("payment.capacityStage.heading", "How many users do you need?")}
|
"payment.capacityStage.subheading",
|
||||||
</Text>
|
"Covers everyone you invite, in blocks of {{users}} users.",
|
||||||
<Text size="sm" c="dimmed" mt={4}>
|
{ users: USERS_PER_BLOCK },
|
||||||
{t(
|
)}
|
||||||
"payment.capacityStage.subheading",
|
</Text>
|
||||||
"Each server covers {{users}} users. Add servers to cover more.",
|
|
||||||
{ users: USERS_PER_SERVER },
|
|
||||||
)}
|
|
||||||
</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
|
<NumberInput
|
||||||
label={t("payment.capacityStage.serversLabel", "Servers")}
|
label={t("payment.capacityStage.customLabel", "Number of users")}
|
||||||
value={serverQuantity}
|
description={t(
|
||||||
onChange={(value) => setClamped(Number(value) || minServers)}
|
"payment.capacityStage.customHint",
|
||||||
min={minServers}
|
"Rounded up to the next block of {{users}}.",
|
||||||
max={SELF_SERVE_MAX_SERVERS}
|
{ users: USERS_PER_BLOCK },
|
||||||
|
)}
|
||||||
|
value={covered}
|
||||||
|
onChange={(value) => selectUsers(Number(value) || minUsers)}
|
||||||
|
min={minUsers}
|
||||||
|
max={maxUsers}
|
||||||
|
step={USERS_PER_BLOCK}
|
||||||
clampBehavior="strict"
|
clampBehavior="strict"
|
||||||
allowDecimal={false}
|
allowDecimal={false}
|
||||||
allowNegative={false}
|
allowNegative={false}
|
||||||
size="lg"
|
style={{ width: 220 }}
|
||||||
style={{ width: 140 }}
|
|
||||||
/>
|
/>
|
||||||
<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 && (
|
{belowCurrentUsage && (
|
||||||
<Alert color="yellow" variant="light">
|
<Alert color="yellow" variant="light">
|
||||||
{t(
|
{t(
|
||||||
"payment.capacityStage.minimumForCurrentUsers",
|
"payment.capacityStage.minimumForCurrentUsers",
|
||||||
"You have {{users}} users, so you need at least {{servers}} servers.",
|
"You have {{users}} users, so the plan must cover at least {{minimum}}.",
|
||||||
{ users: currentUsers, servers: minServers },
|
{ users: currentUsers, minimum: minUsers },
|
||||||
)}
|
)}
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
@@ -112,29 +141,37 @@ export const CapacityStage: React.FC<CapacityStageProps> = ({
|
|||||||
<Stack gap="xs">
|
<Stack gap="xs">
|
||||||
<Group justify="space-between">
|
<Group justify="space-between">
|
||||||
<Text size="sm" c="dimmed">
|
<Text size="sm" c="dimmed">
|
||||||
{t("payment.capacityStage.lineItem", "Servers x {{servers}}", {
|
{t(
|
||||||
servers: serverQuantity,
|
"payment.capacityStage.lineItem",
|
||||||
})}
|
"Team · {{price}}{{period}} per {{block}} users",
|
||||||
|
{
|
||||||
|
price: formatPrice(blockPrice, currency, 0),
|
||||||
|
period,
|
||||||
|
block: USERS_PER_BLOCK,
|
||||||
|
},
|
||||||
|
)}
|
||||||
</Text>
|
</Text>
|
||||||
<Text size="sm">
|
<Text size="sm" fw={500}>
|
||||||
{t("payment.capacityStage.addedUsers", "+{{users}} users", {
|
{t("payment.capacityStage.userTotal", "{{users}} users", {
|
||||||
users: covered,
|
users: covered,
|
||||||
})}
|
})}
|
||||||
</Text>
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
<Group justify="space-between" align="baseline">
|
<Group justify="space-between" align="baseline">
|
||||||
<Text fw={600}>
|
<Text fw={600}>
|
||||||
{isYearly
|
{t("payment.capacityStage.dueToday", "Due today")}
|
||||||
? t("payment.capacityStage.totalYearly", "Total, billed yearly")
|
|
||||||
: t(
|
|
||||||
"payment.capacityStage.totalMonthly",
|
|
||||||
"Total, billed monthly",
|
|
||||||
)}
|
|
||||||
</Text>
|
</Text>
|
||||||
<Text size="xl" fw={700}>
|
<Text size="xl" fw={700}>
|
||||||
{formatPrice(total, currency)}
|
{formatPrice(total, currency)}
|
||||||
</Text>
|
</Text>
|
||||||
</Group>
|
</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>
|
||||||
|
|
||||||
<Stack gap="sm">
|
<Stack gap="sm">
|
||||||
|
|||||||
+26
-26
@@ -1,42 +1,42 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import {
|
import {
|
||||||
USERS_PER_SERVER,
|
USERS_PER_BLOCK,
|
||||||
SELF_SERVE_MAX_SERVERS,
|
SELF_SERVE_MAX_BLOCKS,
|
||||||
ENTERPRISE_ADVISORY_USERS,
|
ENTERPRISE_ADVISORY_USERS,
|
||||||
usersForServers,
|
usersForBlocks,
|
||||||
serversForUsers,
|
blocksForUsers,
|
||||||
shouldOfferEnterprise,
|
shouldOfferEnterprise,
|
||||||
} from "@app/components/shared/stripeCheckout/utils/capacity";
|
} from "@app/components/shared/stripeCheckout/utils/capacity";
|
||||||
|
|
||||||
describe("usersForServers", () => {
|
describe("usersForBlocks", () => {
|
||||||
it("multiplies servers by the block size", () => {
|
it("multiplies blocks by the block size", () => {
|
||||||
expect(usersForServers(1)).toBe(USERS_PER_SERVER);
|
expect(usersForBlocks(1)).toBe(USERS_PER_BLOCK);
|
||||||
expect(usersForServers(3)).toBe(USERS_PER_SERVER * 3);
|
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.
|
// Zero would read as "no capacity" and price the plan at nothing.
|
||||||
expect(usersForServers(0)).toBe(USERS_PER_SERVER);
|
expect(usersForBlocks(0)).toBe(USERS_PER_BLOCK);
|
||||||
expect(usersForServers(-2)).toBe(USERS_PER_SERVER);
|
expect(usersForBlocks(-2)).toBe(USERS_PER_BLOCK);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("serversForUsers", () => {
|
describe("blocksForUsers", () => {
|
||||||
it("rounds part-full servers up", () => {
|
it("rounds a part-full block up", () => {
|
||||||
expect(serversForUsers(1)).toBe(1);
|
expect(blocksForUsers(1)).toBe(1);
|
||||||
expect(serversForUsers(USERS_PER_SERVER)).toBe(1);
|
expect(blocksForUsers(USERS_PER_BLOCK)).toBe(1);
|
||||||
expect(serversForUsers(USERS_PER_SERVER + 1)).toBe(2);
|
expect(blocksForUsers(USERS_PER_BLOCK + 1)).toBe(2);
|
||||||
expect(serversForUsers(240)).toBe(3);
|
expect(blocksForUsers(240)).toBe(3);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("never returns zero servers", () => {
|
it("never returns zero blocks", () => {
|
||||||
// Checkout seeds the stepper from this, and a zero would render a blocked stage.
|
// Checkout seeds the picker from this, and a zero would render a blocked stage.
|
||||||
expect(serversForUsers(0)).toBe(1);
|
expect(blocksForUsers(0)).toBe(1);
|
||||||
expect(serversForUsers(-5)).toBe(1);
|
expect(blocksForUsers(-5)).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("round-trips with usersForServers", () => {
|
it("round-trips with usersForBlocks", () => {
|
||||||
expect(serversForUsers(usersForServers(4))).toBe(4);
|
expect(blocksForUsers(usersForBlocks(4))).toBe(4);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -47,12 +47,12 @@ describe("shouldOfferEnterprise", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("offers the quote once the purchase reaches the self-serve maximum", () => {
|
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", () => {
|
it("offers the quote once the resulting capacity is enterprise-sized", () => {
|
||||||
const servers = Math.ceil(ENTERPRISE_ADVISORY_USERS / USERS_PER_SERVER);
|
const servers = Math.ceil(ENTERPRISE_ADVISORY_USERS / USERS_PER_BLOCK);
|
||||||
expect(usersForServers(servers)).toBeGreaterThanOrEqual(
|
expect(usersForBlocks(servers)).toBeGreaterThanOrEqual(
|
||||||
ENTERPRISE_ADVISORY_USERS,
|
ENTERPRISE_ADVISORY_USERS,
|
||||||
);
|
);
|
||||||
expect(shouldOfferEnterprise(servers)).toBe(true);
|
expect(shouldOfferEnterprise(servers)).toBe(true);
|
||||||
|
|||||||
+22
-17
@@ -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
|
* 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
|
* 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
|
* `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.
|
* surfaces read `userBlockSize` off `/license-info` rather than this constant.
|
||||||
*/
|
*/
|
||||||
export const USERS_PER_SERVER = 100;
|
export const USERS_PER_BLOCK = 100;
|
||||||
|
|
||||||
/**
|
/** Blocks a buyer can put through self-serve checkout in one go. */
|
||||||
* Servers a buyer can put through self-serve checkout in one go. Past this the enterprise
|
export const SELF_SERVE_MAX_BLOCKS = 5;
|
||||||
* conversation is offered alongside the purchase, never instead of it.
|
|
||||||
*/
|
|
||||||
export const SELF_SERVE_MAX_SERVERS = 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;
|
export const ENTERPRISE_ADVISORY_USERS = 1000;
|
||||||
|
|
||||||
/** Servers needed to cover a given number of users. Always at least one. */
|
/** The user counts offered as one-click presets, before "Other". */
|
||||||
export function serversForUsers(users: number): number {
|
export const USER_PRESETS = [100, 200, 300, 400];
|
||||||
return Math.max(1, Math.ceil(Math.max(0, users) / USERS_PER_SERVER));
|
|
||||||
|
/** 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. */
|
/** Blocks needed to cover a given number of users. Always at least one. */
|
||||||
export function usersForServers(servers: number): number {
|
export function blocksForUsers(users: number): number {
|
||||||
return Math.max(1, servers) * USERS_PER_SERVER;
|
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
|
* 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.
|
* 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 (
|
return (
|
||||||
servers >= SELF_SERVE_MAX_SERVERS ||
|
blocks >= SELF_SERVE_MAX_BLOCKS ||
|
||||||
usersForServers(servers) >= ENTERPRISE_ADVISORY_USERS
|
usersForBlocks(blocks) >= ENTERPRISE_ADVISORY_USERS
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-5
@@ -37,11 +37,9 @@ export const getModalTitle = (
|
|||||||
{ planName },
|
{ planName },
|
||||||
);
|
);
|
||||||
case "capacity":
|
case "capacity":
|
||||||
return t(
|
return t("payment.capacityStage.modalTitle", "Upgrade to {{planName}}", {
|
||||||
"payment.capacityStage.modalTitle",
|
planName,
|
||||||
"Choose Capacity - {{planName}}",
|
});
|
||||||
{ planName },
|
|
||||||
);
|
|
||||||
case "payment":
|
case "payment":
|
||||||
return t(
|
return t(
|
||||||
"payment.paymentStage.modalTitle",
|
"payment.paymentStage.modalTitle",
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ export interface CheckoutSessionRequest {
|
|||||||
current_license_key?: string; // Current license key for upgrades
|
current_license_key?: string; // Current license key for upgrades
|
||||||
requires_seats?: boolean; // Whether to add adjustable seat pricing
|
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)
|
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
|
email?: string; // Customer email for checkout pre-fill
|
||||||
successUrl?: string;
|
successUrl?: string;
|
||||||
cancelUrl?: string;
|
cancelUrl?: string;
|
||||||
@@ -187,7 +187,7 @@ const licenseService = {
|
|||||||
{
|
{
|
||||||
id: "selfhosted:server:monthly",
|
id: "selfhosted:server:monthly",
|
||||||
lookupKey: "selfhosted:server:monthly",
|
lookupKey: "selfhosted:server:monthly",
|
||||||
name: "Server - Monthly",
|
name: "Team - Monthly",
|
||||||
price: getPriceInfo("selfhosted:server:monthly"),
|
price: getPriceInfo("selfhosted:server:monthly"),
|
||||||
currency: currencySymbol,
|
currency: currencySymbol,
|
||||||
period: "/month",
|
period: "/month",
|
||||||
@@ -198,7 +198,7 @@ const licenseService = {
|
|||||||
{
|
{
|
||||||
id: "selfhosted:server:yearly",
|
id: "selfhosted:server:yearly",
|
||||||
lookupKey: "selfhosted:server:yearly",
|
lookupKey: "selfhosted:server:yearly",
|
||||||
name: "Server - Yearly",
|
name: "Team - Yearly",
|
||||||
price: getPriceInfo("selfhosted:server:yearly"),
|
price: getPriceInfo("selfhosted:server:yearly"),
|
||||||
currency: currencySymbol,
|
currency: currencySymbol,
|
||||||
period: "/year",
|
period: "/year",
|
||||||
@@ -298,7 +298,7 @@ const licenseService = {
|
|||||||
if (serverMonthly || serverYearly) {
|
if (serverMonthly || serverYearly) {
|
||||||
groups.push({
|
groups.push({
|
||||||
tier: "server",
|
tier: "server",
|
||||||
name: "Server",
|
name: "Team",
|
||||||
monthly: serverMonthly || null,
|
monthly: serverMonthly || null,
|
||||||
yearly: serverYearly || null,
|
yearly: serverYearly || null,
|
||||||
features: (serverMonthly || serverYearly)!.features,
|
features: (serverMonthly || serverYearly)!.features,
|
||||||
|
|||||||
Reference in New Issue
Block a user