History

Add enterprise billing plan (#860)

Rhys Sullivan committed 5 months ago

Commit 45f78051 parent c598d648 files · +106 −71Browse files

Unified · Split

8 files
  1. apps/cloud/autumn.config.ts +15 −0
  2. apps/cloud/src/auth/organization-limits.node.test.ts +3 −0
  3. apps/cloud/src/org/member-limits.node.test.ts +1 −0
  4. apps/cloud/src/org/member-limits.ts +1 −0
  5. apps/cloud/src/routes/billing.tsx +1 −0
  6. apps/cloud/src/routes/billing_.plans.tsx +64 −69
  7. apps/cloud/src/services/autumn-plans.ts +2 −2
  8. autumn.config.ts +19 −0
apps/cloud/autumn.config.ts+15−0
@@ -68,3 +68,18 @@ export const team = plan({ · 67 unmodified lines
68 }),68 }),
69 ],69 ],
70});70});
71
72export const enterprise = plan({
73 id: "enterprise",
74 name: "Enterprise",
75 items: [
76 item({
77 featureId: executions.id,
78 unlimited: true,
79 reset: { interval: "month" },
80 }),
81 item({
82 featureId: domainVerification.id,
83 }),
84 ],
85});
apps/cloud/src/auth/organization-limits.node.test.ts+3−0
@@ -11,6 +11,9 @@ describe("organization limits", () => { · 10 unmodified lines
11 it("treats active and trialing paid org subscriptions as paid", () => {11 it("treats active and trialing paid org subscriptions as paid", () => {
12 expect(hasPaidOrganizationSubscription([{ planId: "team", status: "active" }])).toBe(true);12 expect(hasPaidOrganizationSubscription([{ planId: "team", status: "active" }])).toBe(true);
13 expect(hasPaidOrganizationSubscription([{ planId: "team", status: "trialing" }])).toBe(true);13 expect(hasPaidOrganizationSubscription([{ planId: "team", status: "trialing" }])).toBe(true);
14 expect(hasPaidOrganizationSubscription([{ planId: "enterprise", status: "active" }])).toBe(
15 true,
16 );
14 });17 });
15 18
16 it("does not treat inactive paid plans or free plans as paid", () => {19 it("does not treat inactive paid plans or free plans as paid", () => {
apps/cloud/src/org/member-limits.node.test.ts+1−0
@@ -33,6 +33,7 @@ describe("member limits", () => { · 32 unmodified lines
33 it("resolves member limits from the selected plan", () => {33 it("resolves member limits from the selected plan", () => {
34 expect(getMemberLimitForPlan("free")).toBe(3);34 expect(getMemberLimitForPlan("free")).toBe(3);
35 expect(getMemberLimitForPlan("team")).toBeNull();35 expect(getMemberLimitForPlan("team")).toBeNull();
36 expect(getMemberLimitForPlan("enterprise")).toBeNull();
36 expect(getMemberLimitForPlan("unknown")).toBe(3);37 expect(getMemberLimitForPlan("unknown")).toBe(3);
37 });38 });
38});39});
apps/cloud/src/org/member-limits.ts+1−0
@@ -4,6 +4,7 @@ const MEMBER_LIMITS: Record<string, number | null> = { · 3 unmodified lines
4 free: 3,4 free: 3,
5 "free-pay-as-you-go": 3,5 "free-pay-as-you-go": 3,
6 team: null,6 team: null,
7 enterprise: null,
7};8};
8 9
9export const DEFAULT_MEMBER_LIMIT = 3;10export const DEFAULT_MEMBER_LIMIT = 3;
apps/cloud/src/routes/billing.tsx+1−0
@@ -12,6 +12,7 @@ export const Route = createFileRoute("/billing")({ · 11 unmodified lines
12const PLAN_TAGLINES: Record<string, string> = {12const PLAN_TAGLINES: Record<string, string> = {
13 free: "Free for up to 3 members",13 free: "Free for up to 3 members",
14 team: "$49 per organization",14 team: "$49 per organization",
15 enterprise: "Custom enterprise agreement",
15};16};
16 17
17function BillingPage() {18function BillingPage() {
apps/cloud/src/routes/billing_.plans.tsx+64−69
@@ -20,6 +20,14 @@ export const Route = createFileRoute("/billing_/plans")({ · 19 unmodified lines
20 component: PlansPage,20 component: PlansPage,
21});21});
22 22
23const ENTERPRISE_FEATURES = [
24 "Self-hosted or dedicated cloud deployment support",
25 "SSO / SAML & SCIM provisioning",
26 "Audit logs for every tool call",
27 "Dedicated support & onboarding",
28 "Security reviews, DPA & SOC 2 on request",
29];
30
23const PLAN_META: Record<string, { tagline: string; inherits?: string; features: string[] }> = {31const PLAN_META: Record<string, { tagline: string; inherits?: string; features: string[] }> = {
24 free: {32 free: {
25 tagline: "For small teams getting started",33 tagline: "For small teams getting started",
@@ -40,6 +48,11 @@ const PLAN_META: Record<string, { tagline: string; inherits?: string; features: · 14 unmodified lines
40 "$0.20 per 1,000 additional executions",48 "$0.20 per 1,000 additional executions",
41 ],49 ],
42 },50 },
51 enterprise: {
52 tagline: "For orgs with custom needs",
53 inherits: "Team",
54 features: ENTERPRISE_FEATURES,
55 },
43};56};
44 57
45const ACTION_LABELS: Record<string, string> = {58const ACTION_LABELS: Record<string, string> = {
@@ -50,28 +63,7 @@ const ACTION_LABELS: Record<string, string> = { · 4 unmodified lines
50 purchase: "Purchase",63 purchase: "Purchase",
51};64};
52 65
53const ENTERPRISE_FEATURES = [66const PLAN_ORDER = ["free", "team", "enterprise"];
54 "Self-hosted or dedicated cloud deployment support",
55 "SSO / SAML & SCIM provisioning",
56 "Audit logs for every tool call",
57 "Dedicated support & onboarding",
58 "Security reviews, DPA & SOC 2 on request",
59];
60
61const ENTERPRISE_MAILTO = `mailto:rhys@executor.sh?subject=${encodeURIComponent(
62 "Executor Enterprise inquiry",
63)}&body=${encodeURIComponent(
64 [
65 "Hi,",
66 "",
67 "We're interested in Executor Enterprise.",
68 "",
69 "Company:",
70 "Team size:",
71 "Use case:",
72 "Requirements (SSO, self-hosted, compliance, etc.):",
73 ].join("\n"),
74)}`;
75 67
76function PlansPage() {68function PlansPage() {
77 const { attach, openCustomerPortal, isLoading: customerLoading } = useCustomer();69 const { attach, openCustomerPortal, isLoading: customerLoading } = useCustomer();
@@ -80,8 +72,8 @@ function PlansPage() { · 2 unmodified lines
80 72
81 const isLoading = customerLoading || plansLoading;73 const isLoading = customerLoading || plansLoading;
82 74
83 const selfServePlans = (plans ?? ([] as Plan[])).filter(75 const selfServePlans = PLAN_ORDER.flatMap((id) =>
84 (p: Plan) => p.id === "free" || p.id === "team",76 (plans ?? ([] as Plan[])).filter((plan: Plan) => plan.id === id),
85 );77 );
86 78
87 return (79 return (
@@ -136,6 +128,7 @@ function PlansPage() { · 48 unmodified lines
136 const isScheduled = status === "scheduled";128 const isScheduled = status === "scheduled";
137 const label = isCanceling ? "Resume" : (ACTION_LABELS[action] ?? "Select");129 const label = isCanceling ? "Resume" : (ACTION_LABELS[action] ?? "Select");
138 const isUpgradeAction = action === "upgrade" || action === "activate";130 const isUpgradeAction = action === "upgrade" || action === "activate";
131 const isEnterprise = plan.id === "enterprise";
139 132
140 return (133 return (
141 <div134 <div
@@ -173,14 +166,14 @@ function PlansPage() { · 31 unmodified lines
173 166
174 <div className="mt-4 flex items-baseline gap-1.5">167 <div className="mt-4 flex items-baseline gap-1.5">
175 <span className="text-2xl font-semibold text-foreground tabular-nums">168 <span className="text-2xl font-semibold text-foreground tabular-nums">
176 ${plan.price?.amount ?? 0}169 {plan.id === "enterprise" ? "Custom" : `$${plan.price?.amount ?? 0}`}
177 </span>170 </span>
178 {plan.price?.interval && (171 {plan.id !== "enterprise" && plan.price?.interval && (
179 <span className="text-sm text-muted-foreground">172 <span className="text-sm text-muted-foreground">
180 USD / org / {plan.price.interval}173 USD / org / {plan.price.interval}
181 </span>174 </span>
182 )}175 )}
183 {!plan.price?.interval && (176 {plan.id !== "enterprise" && !plan.price?.interval && (
184 <span className="text-sm text-muted-foreground">USD</span>177 <span className="text-sm text-muted-foreground">USD</span>
185 )}178 )}
186 </div>179 </div>
@@ -190,6 +183,8 @@ function PlansPage() { · 3 unmodified lines
190 <div className="flex h-9 items-center justify-center rounded-md border border-border bg-muted/30 text-sm font-medium text-muted-foreground">183 <div className="flex h-9 items-center justify-center rounded-md border border-border bg-muted/30 text-sm font-medium text-muted-foreground">
191 {isCurrent ? "Current plan" : "Scheduled"}184 {isCurrent ? "Current plan" : "Scheduled"}
192 </div>185 </div>
186 ) : isEnterprise ? (
187 <EnterpriseContactDialog />
193 ) : isCanceling ? (188 ) : isCanceling ? (
194 <Button189 <Button
195 type="button"190 type="button"
@@ -251,48 +246,6 @@ function PlansPage() { · 55 unmodified lines
251 </div>246 </div>
252 );247 );
253 })}248 })}
254
255 <div className="flex flex-col rounded-xl border border-border p-5">
256 <div className="flex h-6 items-center justify-between">
257 <p className="text-base font-semibold text-foreground leading-none">Enterprise</p>
258 </div>
259 <p className="mt-1 text-sm text-muted-foreground">For orgs with custom needs</p>
260
261 <div className="mt-4 flex items-baseline gap-1.5">
262 <span className="text-2xl font-semibold text-foreground tabular-nums">Custom</span>
263 </div>
264
265 <div className="mt-4">
266 <a
267 href={ENTERPRISE_MAILTO}
268 className="flex h-9 w-full items-center justify-center rounded-md border border-border bg-background text-sm font-medium text-foreground transition-colors hover:bg-muted"
269 >
270 Contact us
271 </a>
272 </div>
273
274 <p className="mt-5 text-xs font-medium text-foreground">Everything in Team, plus</p>
275 <ul role="list" className="mt-2 space-y-2">
276 {ENTERPRISE_FEATURES.map((f) => (
277 <li key={f} className="flex items-start gap-2 text-xs text-muted-foreground">
278 <svg
279 viewBox="0 0 16 16"
280 fill="none"
281 className="mt-px size-3.5 shrink-0 text-primary/60"
282 >
283 <path
284 d="M3.5 8.5L6.5 11.5L12.5 5"
285 stroke="currentColor"
286 strokeWidth="1.5"
287 strokeLinecap="round"
288 strokeLinejoin="round"
289 />
290 </svg>
291 <span>{f}</span>
292 </li>
293 ))}
294 </ul>
295 </div>
296 </div>249 </div>
297 )}250 )}
298 251
@@ -302,6 +255,48 @@ function PlansPage() { · 3 unmodified lines
302 );255 );
303}256}
304 257
258function EnterpriseContactDialog() {
259 const [open, setOpen] = useState(false);
260
261 return (
262 <Dialog open={open} onOpenChange={setOpen}>
263 <DialogTrigger asChild>
264 <Button
265 type="button"
266 variant="outline"
267 className="flex h-9 w-full items-center justify-center rounded-md text-sm font-medium"
268 >
269 Contact us
270 </Button>
271 </DialogTrigger>
272 <DialogContent>
273 <DialogHeader>
274 <DialogTitle>Talk to us about Enterprise</DialogTitle>
275 <DialogDescription>
276 Add{" "}
277 <a className="font-medium text-foreground underline" href="mailto:rhys@executor.sh">
278 rhys@executor.sh
279 </a>{" "}
280 on Slack Connect and send the org name, team size, and anything you need covered.
281 </DialogDescription>
282 </DialogHeader>
283 <DialogFooter>
284 <DialogClose asChild>
285 <Button type="button" variant="outline">
286 Done
287 </Button>
288 </DialogClose>
289 <Button asChild>
290 <a href="mailto:rhys@executor.sh?subject=Executor%20Enterprise%20inquiry">
291 Email instead
292 </a>
293 </Button>
294 </DialogFooter>
295 </DialogContent>
296 </Dialog>
297 );
298}
299
305function SlackContactCta() {300function SlackContactCta() {
306 const [open, setOpen] = useState(false);301 const [open, setOpen] = useState(false);
307 302
apps/cloud/src/services/autumn-plans.ts+2−2
@@ -1,5 +1,5 @@
1import { team } from "../../autumn.config";1import { enterprise, team } from "../../autumn.config";
2 2
3export const PAID_AUTUMN_PLAN_IDS = new Set([team.id]);3export const PAID_AUTUMN_PLAN_IDS = new Set([team.id, enterprise.id]);
4 4
5export const ACTIVE_AUTUMN_SUBSCRIPTION_STATUSES = new Set(["active", "trialing"]);5export const ACTIVE_AUTUMN_SUBSCRIPTION_STATUSES = new Set(["active", "trialing"]);
autumn.config.ts+19−0
@@ -83,3 +83,22 @@ export const team = plan({ · 82 unmodified lines
83 }),83 }),
84 ],84 ],
85});85});
86
87export const enterprise = plan({
88 id: "enterprise",
89 name: "Enterprise",
90 addOn: false,
91 autoEnable: false,
92 items: [
93 item({
94 featureId: executions.id,
95 unlimited: true,
96 reset: {
97 interval: "month",
98 },
99 }),
100 item({
101 featureId: domainVerification.id,
102 }),
103 ],
104});