Skip to content

Commit d414763

Browse files
authored
feat(checkout): let buyers choose Server capacity (#7782)
Adds the capacity step to self-hosted checkout. the field is ignored until [#325 ](Stirling-Tools/Stirling-PDF-SaaS#325) lands. <img width="1262" height="487" alt="image" src="https://github.com/user-attachments/assets/c22d0b15-103a-491a-bd98-df772e63c71b" /> ## What it does A capacity stage 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 exactly 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" without converting anything themselves. `server_quantity` now rides `createCheckoutSession` through to the edge function. 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 current usage.** An installation running 240 users cannot buy fewer than three servers. Reducing capacity is a renewal conversation, not something checkout should do by stranding accounts that already exist. **At five servers, or a thousand users, it offers an enterprise quote** beside the purchase. Deliberately an option and not a gate — self-serve checkout still completes. `onContactSales` is optional, so the door only appears where a caller wires it up. ## Review notes - **`USERS_PER_SERVER = 100` is a frontend constant** in `utils/capacity.ts`. The authoritative value lives on `pricing_policy` and is resolved server-side at licence-issue time, but before a purchase there is no licence to read it from and the packaging RPC is not anon-callable. The comment says so. A follow-up could serve it from `stripe-price-lookup`, which the plan page already calls; I kept it out of scope so this PR stays inside one repo. - The `SELF_SERVE_MAX_SERVERS` bound here is cosmetic. `create-checkout` clamps server-side against the policy, so a crafted request cannot exceed it regardless of what the stepper allows. - No copy sweep needed — `plan.features.usersIncluded` already reads "100 users included" on main. ## Testing - 5 unit tests on the capacity arithmetic and the enterprise-door threshold. - 5 Storybook stories: single server, multiple servers monthly, constrained by current users, below current usage (blocked), and the enterprise door.
1 parent 7604729 commit d414763

11 files changed

Lines changed: 437 additions & 3 deletions

File tree

frontend/editor/public/locales/en-US/translation.toml

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6311,6 +6311,23 @@ upgradeSuccess = "Payment successful! Your subscription has been upgraded. The l
63116311
upgradeTitle = "Upgrade to {{planName}}"
63126312
yearly = "Yearly"
63136313

6314+
[payment.capacityStage]
6315+
continue = "Continue to payment"
6316+
customHint = "Rounded up to the next block of {{users}}."
6317+
customLabel = "Number of users"
6318+
dueToday = "Due today"
6319+
enterpriseQuote = "Get an enterprise quote"
6320+
lineItem = "Team · {{price}}{{period}} per {{block}} users"
6321+
minimumForCurrentUsers = "You have {{users}} users, so the plan must cover at least {{minimum}}."
6322+
modalTitle = "Upgrade to {{planName}}"
6323+
other = "Other"
6324+
perMonth = "/mo"
6325+
perYear = "/yr"
6326+
renewalNote = "Renews at {{total}}{{period}}. Cancel any time in Usage & Billing."
6327+
subheading = "Covers everyone you invite, in blocks of {{users}} users."
6328+
usersLabel = "Users"
6329+
userTotal = "{{users}} users"
6330+
63146331
[payment.emailStage]
63156332
continue = "Continue"
63166333
description = "We'll use this to send your license key and receipts."

frontend/editor/src/proprietary/components/shared/stripeCheckout/StripeCheckout.tsx

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ import { useLicensePolling } from "@app/components/shared/stripeCheckout/hooks/u
1818
import { useCheckoutSession } from "@app/components/shared/stripeCheckout/hooks/useCheckoutSession";
1919
import { EmailStage } from "@app/components/shared/stripeCheckout/stages/EmailStage";
2020
import { PlanSelectionStage } from "@app/components/shared/stripeCheckout/stages/PlanSelectionStage";
21+
import { CapacityStage } from "@app/components/shared/stripeCheckout/stages/CapacityStage";
22+
import { blocksForUsers } from "@app/components/shared/stripeCheckout/utils/capacity";
2123
import { PaymentStage } from "@app/components/shared/stripeCheckout/stages/PaymentStage";
2224
import { SuccessStage } from "@app/components/shared/stripeCheckout/stages/SuccessStage";
2325
import { ErrorStage } from "@app/components/shared/stripeCheckout/stages/ErrorStage";
@@ -83,6 +85,7 @@ const StripeCheckout: React.FC<StripeCheckoutProps> = ({
8385
checkoutState.setCurrentLicenseKey,
8486
checkoutState.setPollingStatus,
8587
minimumSeats,
88+
checkoutState.serverQuantity,
8689
polling.pollForLicenseKey,
8790
onSuccess,
8891
onError,
@@ -106,9 +109,19 @@ const StripeCheckout: React.FC<StripeCheckoutProps> = ({
106109
}
107110
};
108111

112+
// Only the Team tier is sold by capacity. Enterprise is priced per seat and free has nothing to
113+
// size, so both go straight to payment.
114+
const sellsCapacity = planGroup.tier === "server";
115+
109116
// Plan selection handler
110117
const handlePlanSelect = (period: "monthly" | "yearly") => {
111118
checkoutState.setSelectedPeriod(period);
119+
if (sellsCapacity) {
120+
// Arrive on the capacity an installation already needs rather than on a blocked minimum.
121+
checkoutState.setServerQuantity(blocksForUsers(minimumSeats));
122+
navigation.goToStage("capacity");
123+
return;
124+
}
112125
navigation.goToStage("payment");
113126
};
114127

@@ -234,6 +247,17 @@ const StripeCheckout: React.FC<StripeCheckoutProps> = ({
234247
/>
235248
);
236249

250+
case "capacity":
251+
return (
252+
<CapacityStage
253+
selectedPlan={checkoutState.selectedPlan}
254+
serverQuantity={checkoutState.serverQuantity}
255+
setServerQuantity={checkoutState.setServerQuantity}
256+
currentUsers={minimumSeats}
257+
onContinue={() => navigation.goToStage("payment")}
258+
/>
259+
);
260+
237261
case "payment":
238262
return (
239263
<PaymentStage

frontend/editor/src/proprietary/components/shared/stripeCheckout/hooks/useCheckoutSession.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ export const useCheckoutSession = (
1919
setCurrentLicenseKey: React.Dispatch<React.SetStateAction<string | null>>,
2020
setPollingStatus: React.Dispatch<React.SetStateAction<PollingStatus>>,
2121
minimumSeats: number,
22+
serverQuantity: number,
2223
pollForLicenseKey: (installId: string) => Promise<void>,
2324
onSuccess?: (sessionId: string) => void,
2425
onError?: (error: string) => void,
@@ -76,6 +77,7 @@ export const useCheckoutSession = (
7677
current_license_key: existingLicenseKey,
7778
requires_seats: selectedPlan.requiresSeats,
7879
seat_count: Math.max(1, Math.min(minimumSeats || 1, 10000)),
80+
server_quantity: Math.max(1, serverQuantity || 1),
7981
email: state.email, // Pass collected email from Stage 1
8082
});
8183

@@ -111,6 +113,7 @@ export const useCheckoutSession = (
111113
state.email,
112114
installationId,
113115
minimumSeats,
116+
serverQuantity,
114117
setState,
115118
setInstallationId,
116119
setCurrentLicenseKey,

frontend/editor/src/proprietary/components/shared/stripeCheckout/hooks/useCheckoutState.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ export const useCheckoutState = (planGroup: PlanTierGroup) => {
2020
const [selectedPeriod, setSelectedPeriod] = useState<"monthly" | "yearly">(
2121
planGroup.yearly ? "yearly" : "monthly",
2222
);
23+
// Blocks of users to buy. Only the Team tier asks; every other tier stays at one.
24+
const [serverQuantity, setServerQuantity] = useState<number>(1);
2325
const [installationId, setInstallationId] = useState<string | null>(null);
2426
const [currentLicenseKey, setCurrentLicenseKey] = useState<string | null>(
2527
null,
@@ -50,6 +52,7 @@ export const useCheckoutState = (planGroup: PlanTierGroup) => {
5052
setCurrentLicenseKey(null);
5153
setLicenseKey(null);
5254
setSelectedPeriod(planGroup.yearly ? "yearly" : "monthly");
55+
setServerQuantity(1);
5356
}, [planGroup]);
5457

5558
return {
@@ -64,6 +67,8 @@ export const useCheckoutState = (planGroup: PlanTierGroup) => {
6467
setEmailError,
6568
selectedPeriod,
6669
setSelectedPeriod,
70+
serverQuantity,
71+
setServerQuantity,
6772
installationId,
6873
setInstallationId,
6974
currentLicenseKey,
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import type { Meta, StoryObj } from "@storybook/react-vite";
2+
import { useState } from "react";
3+
import { CapacityStage } from "@app/components/shared/stripeCheckout/stages/CapacityStage";
4+
import { PlanTier } from "@app/services/licenseService";
5+
6+
/**
7+
* The capacity step of the Stripe checkout, between billing period and payment. Only the Team tier
8+
* reaches it: the buyer picks users, and the plan is priced per block of 100.
9+
*/
10+
const yearlyPlan: PlanTier = {
11+
id: "server-yearly",
12+
name: "Team",
13+
price: 990,
14+
currency: "$",
15+
period: "/year",
16+
features: [],
17+
highlights: [],
18+
lookupKey: "selfhosted:server:yearly",
19+
};
20+
21+
const monthlyPlan: PlanTier = {
22+
...yearlyPlan,
23+
id: "server-monthly",
24+
price: 99,
25+
period: "/month",
26+
};
27+
28+
const meta = {
29+
title: "StripeCheckout/CapacityStage",
30+
component: CapacityStage,
31+
args: {
32+
selectedPlan: yearlyPlan,
33+
serverQuantity: 1,
34+
setServerQuantity: () => {},
35+
onContinue: () => {},
36+
},
37+
// The stepper is the point of this stage, so stories own the quantity and let it move.
38+
render: function Interactive(args) {
39+
const [quantity, setQuantity] = useState(args.serverQuantity);
40+
return (
41+
<CapacityStage
42+
{...args}
43+
serverQuantity={quantity}
44+
setServerQuantity={setQuantity}
45+
/>
46+
);
47+
},
48+
} satisfies Meta<typeof CapacityStage>;
49+
50+
export default meta;
51+
type Story = StoryObj<typeof meta>;
52+
53+
/** A fresh purchase: the smallest block, 100 users. */
54+
export const SingleBlock: Story = {};
55+
56+
/** 300 users on the monthly plan, so the total is three block prices. */
57+
export const ThreeBlocksMonthly: Story = {
58+
args: { selectedPlan: monthlyPlan, serverQuantity: 3 },
59+
};
60+
61+
/**
62+
* An installation already running 240 users cannot buy cover for fewer than 300. Reducing capacity
63+
* is a renewal conversation, not something checkout does by stranding accounts.
64+
*/
65+
export const ConstrainedByCurrentUsers: Story = {
66+
args: { serverQuantity: 3, currentUsers: 240 },
67+
};
68+
69+
/** Below the minimum the continue button is blocked and the reason is stated. */
70+
export const BelowCurrentUsage: Story = {
71+
args: { serverQuantity: 1, currentUsers: 240 },
72+
};
73+
74+
/**
75+
* At the self-serve maximum the enterprise quote is offered beside the purchase. It is an option,
76+
* never a wall: self-serve checkout still completes.
77+
*/
78+
export const OffersEnterpriseQuote: Story = {
79+
args: { serverQuantity: 5, onContactSales: () => {} },
80+
};

0 commit comments

Comments
 (0)