From fecf4b2067f48ec95319f1aee72cb8fe03976224 Mon Sep 17 00:00:00 2001 From: Connor Yoh Date: Wed, 2 Sep 2026 14:49:57 +0100 Subject: [PATCH] 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. --- .../shared/stripeCheckout/StripeCheckout.tsx | 9 ++++++++- .../stripeCheckout/stages/CapacityStage.tsx | 6 ++++-- .../stripeCheckout/utils/capacity.test.ts | 20 +++++++++++++++++++ .../shared/stripeCheckout/utils/capacity.ts | 5 +++++ 4 files changed, 37 insertions(+), 3 deletions(-) diff --git a/frontend/editor/src/proprietary/components/shared/stripeCheckout/StripeCheckout.tsx b/frontend/editor/src/proprietary/components/shared/stripeCheckout/StripeCheckout.tsx index 8b3826d264..6cf6818b7e 100644 --- a/frontend/editor/src/proprietary/components/shared/stripeCheckout/StripeCheckout.tsx +++ b/frontend/editor/src/proprietary/components/shared/stripeCheckout/StripeCheckout.tsx @@ -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 = ({ // 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 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 a1415bb439..bfed8470d4 100644 --- a/frontend/editor/src/proprietary/components/shared/stripeCheckout/stages/CapacityStage.tsx +++ b/frontend/editor/src/proprietary/components/shared/stripeCheckout/stages/CapacityStage.tsx @@ -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 = ({ 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); 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 b608f8a19b..b7b94b9fe0 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 @@ -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); 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 d5eaf2ccbe..b96b4dba22 100644 --- a/frontend/editor/src/proprietary/components/shared/stripeCheckout/utils/capacity.ts +++ b/frontend/editor/src/proprietary/components/shared/stripeCheckout/utils/capacity.ts @@ -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;