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
6868 }),
6969 ],
7070 });
71+
72+export 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
1111 it("treats active and trialing paid org subscriptions as paid", () => {
1212 expect(hasPaidOrganizationSubscription([{ planId: "team", status: "active" }])).toBe(true);
1313 expect(hasPaidOrganizationSubscription([{ planId: "team", status: "trialing" }])).toBe(true);
14+ expect(hasPaidOrganizationSubscription([{ planId: "enterprise", status: "active" }])).toBe(
15+ true,
16+ );
1417 });
1518
1619 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
3333 it("resolves member limits from the selected plan", () => {
3434 expect(getMemberLimitForPlan("free")).toBe(3);
3535 expect(getMemberLimitForPlan("team")).toBeNull();
36+ expect(getMemberLimitForPlan("enterprise")).toBeNull();
3637 expect(getMemberLimitForPlan("unknown")).toBe(3);
3738 });
3839 });
apps/cloud/src/org/member-limits.ts+1−0
@@ -4,6 +4,7 @@ const MEMBER_LIMITS: Record<string, number | null> = { · 3 unmodified lines
44 free: 3,
55 "free-pay-as-you-go": 3,
66 team: null,
7+ enterprise: null,
78 };
89
910 export 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
1212 const PLAN_TAGLINES: Record<string, string> = {
1313 free: "Free for up to 3 members",
1414 team: "$49 per organization",
15+ enterprise: "Custom enterprise agreement",
1516 };
1617
1718 function BillingPage() {
apps/cloud/src/routes/billing_.plans.tsx+64−69
@@ -20,6 +20,14 @@ export const Route = createFileRoute("/billing_/plans")({ · 19 unmodified lines
2020 component: PlansPage,
2121 });
2222
23+const 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+
2331 const PLAN_META: Record<string, { tagline: string; inherits?: string; features: string[] }> = {
2432 free: {
2533 tagline: "For small teams getting started",
@@ -40,6 +48,11 @@ const PLAN_META: Record<string, { tagline: string; inherits?: string; features: · 14 unmodified lines
4048 "$0.20 per 1,000 additional executions",
4149 ],
4250 },
51+ enterprise: {
52+ tagline: "For orgs with custom needs",
53+ inherits: "Team",
54+ features: ENTERPRISE_FEATURES,
55+ },
4356 };
4457
4558 const ACTION_LABELS: Record<string, string> = {
@@ -50,28 +63,7 @@ const ACTION_LABELS: Record<string, string> = { · 4 unmodified lines
5063 purchase: "Purchase",
5164 };
5265
53−const ENTERPRISE_FEATURES = [
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−
61−const 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−)}`;
66+const PLAN_ORDER = ["free", "team", "enterprise"];
7567
7668 function PlansPage() {
7769 const { attach, openCustomerPortal, isLoading: customerLoading } = useCustomer();
@@ -80,8 +72,8 @@ function PlansPage() { · 2 unmodified lines
8072
8173 const isLoading = customerLoading || plansLoading;
8274
83− const selfServePlans = (plans ?? ([] as Plan[])).filter(
84− (p: Plan) => p.id === "free" || p.id === "team",
75+ const selfServePlans = PLAN_ORDER.flatMap((id) =>
76+ (plans ?? ([] as Plan[])).filter((plan: Plan) => plan.id === id),
8577 );
8678
8779 return (
@@ -136,6 +128,7 @@ function PlansPage() { · 48 unmodified lines
136128 const isScheduled = status === "scheduled";
137129 const label = isCanceling ? "Resume" : (ACTION_LABELS[action] ?? "Select");
138130 const isUpgradeAction = action === "upgrade" || action === "activate";
131+ const isEnterprise = plan.id === "enterprise";
139132
140133 return (
141134 <div
@@ -173,14 +166,14 @@ function PlansPage() { · 31 unmodified lines
173166
174167 <div className="mt-4 flex items-baseline gap-1.5">
175168 <span className="text-2xl font-semibold text-foreground tabular-nums">
176− ${plan.price?.amount ?? 0}
169+ {plan.id === "enterprise" ? "Custom" : `$${plan.price?.amount ?? 0}`}
177170 </span>
178− {plan.price?.interval && (
171+ {plan.id !== "enterprise" && plan.price?.interval && (
179172 <span className="text-sm text-muted-foreground">
180173 USD / org / {plan.price.interval}
181174 </span>
182175 )}
183− {!plan.price?.interval && (
176+ {plan.id !== "enterprise" && !plan.price?.interval && (
184177 <span className="text-sm text-muted-foreground">USD</span>
185178 )}
186179 </div>
@@ -190,6 +183,8 @@ function PlansPage() { · 3 unmodified lines
190183 <div className="flex h-9 items-center justify-center rounded-md border border-border bg-muted/30 text-sm font-medium text-muted-foreground">
191184 {isCurrent ? "Current plan" : "Scheduled"}
192185 </div>
186+ ) : isEnterprise ? (
187+ <EnterpriseContactDialog />
193188 ) : isCanceling ? (
194189 <Button
195190 type="button"
@@ -251,48 +246,6 @@ function PlansPage() { · 55 unmodified lines
251246 </div>
252247 );
253248 })}
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>
296249 </div>
297250 )}
298251
@@ -302,6 +255,48 @@ function PlansPage() { · 3 unmodified lines
302255 );
303256 }
304257
258+function 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+
305300 function SlackContactCta() {
306301 const [open, setOpen] = useState(false);
307302
apps/cloud/src/services/autumn-plans.ts+2−2
@@ -1,5 +1,5 @@
1−import { team } from "../../autumn.config";
1+import { enterprise, team } from "../../autumn.config";
22
3−export const PAID_AUTUMN_PLAN_IDS = new Set([team.id]);
3+export const PAID_AUTUMN_PLAN_IDS = new Set([team.id, enterprise.id]);
44
55 export 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
8383 }),
8484 ],
8585 });
86+
87+export 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+});