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;