History

Switch billing from per-seat to flat-fee per org + add Slack/email contact

Pricing model - Replace seat-based pricing with org-flat-fee plans: Free (10k execs/mo, 3 members), Free PAYG (10k included + $0.20/1k overage), Team ($49/mo, 250k incl, unlimited members, $0.20/1k overage). Bumped Free included executions from 5k -> 10k for top-of-funnel. - Mark `seats` feature archived; introduce `members` (non-consumable metered) and `domain-verification` (boolean) features. - Update privacy/terms copy from "seat counts" to "membership" / "organization-level" pricing language. - Refresh /billing and /billing/plans UI: trim per-seat add-ons, render Free/Team/Enterprise cards, fix button alignment under conditional badges. Slack/email contact CTA on pricing page - New SlackService wraps Slack Web API: creates `#shared-<email-slug>` channel (retries with random suffix on `name_taken`), posts a hello message, and calls `conversations.inviteShared` with `external_limited`. - POST /api/contact/slack: public endpoint, validates email, verifies a Cloudflare Turnstile token, gates on a 5/min global ratelimit binding, then calls SlackService. Internal SlackError details are logged server-side; the client only sees a generic message. - Pricing page CTA: dialog with Turnstile widget + email/name/note form, prefilled from useAuth() when signed in. Sibling "Email us" mailto: link to rhys@executor.sh. - Wrangler bindings: ratelimit (5/60s), VITE_PUBLIC_TURNSTILE_SITEKEY vars; .env.op gets dev test keys + SLACK_BOT_TOKEN op:// reference.

Rhys Sullivan committed 6 months ago

Commit 47dfd6b1 parent c5d93a317 files · +919 −189Browse files

Showing the changes to apps/cloud/src/services (2 of 17 files). Show all

Unified · Split

2 files
  1. apps/cloud/src/services/autumn.ts +23 −8
  2. apps/cloud/src/services/slack.ts +142 −0
apps/cloud/src/services/autumn.ts+23−8
@@ -28,6 +28,7 @@ export type IAutumnService = Readonly<{ · 27 unmodified lines
28 * user-facing request.28 * user-facing request.
29 */29 */
30 trackExecution: (organizationId: string) => Effect.Effect<void, never, never>;30 trackExecution: (organizationId: string) => Effect.Effect<void, never, never>;
31 trackMemberChange: (organizationId: string, value: number) => Effect.Effect<void, never, never>;
31}>;32}>;
32 33
33// ---------------------------------------------------------------------------34// ---------------------------------------------------------------------------
@@ -44,6 +45,7 @@ const make = Effect.sync(() => { · 10 unmodified lines
44 return {45 return {
45 use: () => notConfigured,46 use: () => notConfigured,
46 trackExecution: () => Effect.void,47 trackExecution: () => Effect.void,
48 trackMemberChange: () => Effect.void,
47 } satisfies IAutumnService;49 } satisfies IAutumnService;
48 }50 }
49 51
@@ -59,9 +61,7 @@ const make = Effect.sync(() => { · 9 unmodified lines
59 Effect.gen(function* () {61 Effect.gen(function* () {
60 yield* Effect.annotateCurrentSpan({ "autumn.customer.id": organizationId });62 yield* Effect.annotateCurrentSpan({ "autumn.customer.id": organizationId });
61 const outcome = yield* Effect.result(63 const outcome = yield* Effect.result(
62 use((c) =>64 use((c) => c.track({ customerId: organizationId, featureId: "executions", value: 1 })),
63 c.track({ customerId: organizationId, featureId: "executions", value: 1 }),
64 ),
65 );65 );
66 if (outcome._tag === "Failure") {66 if (outcome._tag === "Failure") {
67 // Silent billing data loss is worth paging on — autumn.trackExecution67 // Silent billing data loss is worth paging on — autumn.trackExecution
@@ -72,12 +72,27 @@ const make = Effect.sync(() => { · 4 unmodified lines
72 }72 }
73 }).pipe(Effect.withSpan("autumn.trackExecution"));73 }).pipe(Effect.withSpan("autumn.trackExecution"));
74 74
75 return { use, trackExecution } satisfies IAutumnService;75 const trackMemberChange = (organizationId: string, value: number) =>
76 Effect.gen(function* () {
77 yield* Effect.annotateCurrentSpan({
78 "autumn.customer.id": organizationId,
79 "autumn.members.delta": value,
80 });
81 const outcome = yield* Effect.result(
82 use((c) => c.track({ customerId: organizationId, featureId: "members", value })),
83 );
84 if (outcome._tag === "Failure") {
85 console.error("[billing] member tracking failed:", outcome.failure);
86 Sentry.captureException(outcome.failure);
87 yield* Effect.annotateCurrentSpan({ "autumn.track.members.failed": true });
88 }
89 }).pipe(Effect.withSpan("autumn.trackMemberChange"));
90
91 return { use, trackExecution, trackMemberChange } satisfies IAutumnService;
76});92});
77 93
78export class AutumnService extends Context.Service<94export class AutumnService extends Context.Service<AutumnService, IAutumnService>()(
79 AutumnService,95 "@executor-js/cloud/AutumnService",
80 IAutumnService96) {
81>()("@executor-js/cloud/AutumnService") {
82 static Default = Layer.effect(this)(make);97 static Default = Layer.effect(this)(make);
83}98}
apps/cloud/src/services/slack.ts+142−0
@@ -0,0 +1,142 @@
1// ---------------------------------------------------------------------------
2// Slack service — creates a per-customer Slack Connect channel and emails an
3// invite. Used by the pricing-page "Get in touch on Slack" CTA, modeled on
4// WorkOS's onboarding flow.
5// ---------------------------------------------------------------------------
6
7import { env } from "cloudflare:workers";
8import { Context, Data, Effect, Layer } from "effect";
9
10export class SlackError extends Data.TaggedError("SlackError")<{
11 method: string;
12 error: string;
13}> {}
14
15export type ISlackService = Readonly<{
16 createConnectInvite: (input: {
17 email: string;
18 name?: string;
19 note?: string;
20 organization?: string;
21 }) => Effect.Effect<
22 {
23 channel: { id: string; name: string };
24 invite: { invite_id: string; url: string };
25 },
26 SlackError
27 >;
28}>;
29
30const slugifyEmail = (email: string): string =>
31 email
32 .toLowerCase()
33 .replace(/[^a-z0-9]+/g, "-")
34 .replace(/^-+|-+$/g, "")
35 .slice(0, 40);
36
37const randomSuffix = (): string => {
38 const bytes = new Uint8Array(3);
39 crypto.getRandomValues(bytes);
40 return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
41};
42
43type SlackResponse = { ok: boolean; error?: string } & Record<string, unknown>;
44
45const make = Effect.sync(() => {
46 const token = env.SLACK_BOT_TOKEN;
47
48 if (!token) {
49 const notConfigured = (method: string) =>
50 Effect.fail(
51 new SlackError({ method, error: "SLACK_BOT_TOKEN is not configured" }),
52 );
53 return {
54 createConnectInvite: () => notConfigured("createConnectInvite"),
55 } satisfies ISlackService;
56 }
57
58 const call = <A extends SlackResponse>(method: string, body: Record<string, unknown>) =>
59 Effect.tryPromise({
60 try: async (): Promise<A> => {
61 const res = await fetch(`https://slack.com/api/${method}`, {
62 method: "POST",
63 headers: {
64 "content-type": "application/json; charset=utf-8",
65 authorization: `Bearer ${token}`,
66 },
67 body: JSON.stringify(body),
68 });
69 const json = (await res.json()) as A;
70 if (!json.ok) throw new Error(json.error ?? "unknown_slack_error");
71 return json;
72 },
73 catch: (cause) =>
74 new SlackError({
75 method,
76 error: cause instanceof Error ? cause.message : String(cause),
77 }),
78 }).pipe(Effect.withSpan(`slack.${method}`));
79
80 const createConnectInvite: ISlackService["createConnectInvite"] = ({
81 email,
82 name,
83 note,
84 organization,
85 }) =>
86 Effect.gen(function* () {
87 // Slack channel names: lowercase, no spaces, max 80 chars, unique per workspace.
88 const baseName = `shared-${slugifyEmail(email)}`.slice(0, 80);
89 const tryCreate = (n: string) =>
90 call<SlackResponse & { channel: { id: string; name: string } }>(
91 "conversations.create",
92 { name: n, is_private: false },
93 );
94
95 const created = yield* tryCreate(baseName).pipe(
96 Effect.catchTag("SlackError", (err) =>
97 err.error === "name_taken"
98 ? tryCreate(`${baseName}-${randomSuffix()}`.slice(0, 80))
99 : Effect.fail(err),
100 ),
101 );
102 const channel = created.channel;
103
104 const helloLines = [
105 `:wave: New contact from pricing page: ${email}`,
106 name ? `Name: ${name}` : null,
107 organization ? `Org: ${organization}` : null,
108 note ? `Note: ${note}` : null,
109 ].filter(Boolean) as string[];
110
111 yield* call<SlackResponse>("chat.postMessage", {
112 channel: channel.id,
113 text: helloLines.join("\n"),
114 });
115
116 const invite = yield* call<SlackResponse & { invite_id: string; url: string }>(
117 "conversations.inviteShared",
118 {
119 channel: channel.id,
120 emails: [email],
121 // `external_limited: true` scopes the guest to just this channel,
122 // which is what we want for a focused 1:1 support conversation.
123 external_limited: true,
124 },
125 );
126
127 return {
128 channel: { id: channel.id, name: channel.name },
129 invite: { invite_id: invite.invite_id, url: invite.url },
130 };
131 }).pipe(
132 Effect.withSpan("slack.createConnectInvite", { attributes: { "slack.email": email } }),
133 );
134
135 return { createConnectInvite } satisfies ISlackService;
136});
137
138export class SlackService extends Context.Service<SlackService, ISlackService>()(
139 "@executor-js/cloud/SlackService",
140) {
141 static Default = Layer.effect(this)(make);
142}

Keyboard shortcuts

General

Show keyboard shortcuts
?
Focus the current search
/

Issues and pull requests

Open filters
f

Issues, pull requests and Inbox

Select next item
j↓
Select previous item
k↑
Open selected item
Enter
Dismiss filters or clear selection
Esc

Repository code

Go to file
t