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.
This commit is contained in:
Connor Yoh
2026-09-02 14:50:16 +01:00
parent fe40728f10
commit fecf4b2067
4 changed files with 37 additions and 3 deletions
@@ -19,6 +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 { 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";
@@ -115,7 +116,13 @@ const StripeCheckout: React.FC<StripeCheckoutProps> = ({
// Plan selection handler
const handlePlanSelect = (period: "monthly" | "yearly") => {
checkoutState.setSelectedPeriod(period);
navigation.goToStage(sellsCapacity ? "capacity" : "payment");
if (sellsCapacity) {
// Arrive on the capacity an installation already needs rather than on a blocked "1".
checkoutState.setServerQuantity(serversForUsers(minimumSeats));
navigation.goToStage("capacity");
return;
}
navigation.goToStage("payment");
};
// Close handler
@@ -8,6 +8,7 @@ import {
USERS_PER_SERVER,
SELF_SERVE_MAX_SERVERS,
usersForServers,
serversForUsers,
shouldOfferEnterprise,
} from "@app/components/shared/stripeCheckout/utils/capacity";
@@ -45,8 +46,9 @@ export const CapacityStage: React.FC<CapacityStageProps> = ({
const total = unitPrice * serverQuantity;
// Never sell less capacity than is already in use; reducing capacity happens at renewal rather
// than by stranding accounts that already exist.
const minServers = Math.max(1, Math.ceil(currentUsers / USERS_PER_SERVER));
// 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;
const offerEnterprise = shouldOfferEnterprise(serverQuantity);
@@ -4,6 +4,7 @@ import {
SELF_SERVE_MAX_SERVERS,
ENTERPRISE_ADVISORY_USERS,
usersForServers,
serversForUsers,
shouldOfferEnterprise,
} from "@app/components/shared/stripeCheckout/utils/capacity";
@@ -20,6 +21,25 @@ describe("usersForServers", () => {
});
});
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);
});
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("round-trips with usersForServers", () => {
expect(serversForUsers(usersForServers(4))).toBe(4);
});
});
describe("shouldOfferEnterprise", () => {
it("stays quiet for a small purchase", () => {
expect(shouldOfferEnterprise(1)).toBe(false);
@@ -18,6 +18,11 @@ export const SELF_SERVE_MAX_SERVERS = 5;
/** Resulting 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));
}
/** Users covered by a given number of servers. */
export function usersForServers(servers: number): number {
return Math.max(1, servers) * USERS_PER_SERVER;