diff --git a/apps/cloud/@useautumn-sdk.d.ts b/apps/cloud/@useautumn-sdk.d.ts new file mode 100644 index 0000000000..7d19ca4bd2 --- /dev/null +++ b/apps/cloud/@useautumn-sdk.d.ts @@ -0,0 +1,16 @@ +// AUTO-GENERATED by atmn pull +// DO NOT EDIT MANUALLY + +import type {} from "@useautumn/sdk"; + +declare module "@useautumn/sdk" { + // Features + export const seats: Feature; + + // Plans + export const professional: Plan; + + // Base types + export type Feature = import("./autumn.config").Feature; + export type Plan = import("./autumn.config").Plan; +} diff --git a/apps/cloud/autumn.config.ts b/apps/cloud/autumn.config.ts new file mode 100644 index 0000000000..c0e1992015 --- /dev/null +++ b/apps/cloud/autumn.config.ts @@ -0,0 +1,100 @@ +import { feature, item, plan } from "atmn"; + +// Features +export const seats = feature({ + id: "seats", + name: "Seats", + type: "metered", + consumable: false, +}); + +export const executions = feature({ + id: "executions", + name: "Executions", + type: "metered", + consumable: true, +}); + +// Plans +export const free = plan({ + id: "free", + name: "Free", + autoEnable: true, + items: [ + item({ + featureId: executions.id, + included: 5000, + reset: { interval: "month" }, + }), + ], +}); + +export const hobby = plan({ + id: "hobby", + name: "Hobby", + price: { + amount: 10, + interval: "month", + }, + items: [ + item({ + featureId: seats.id, + included: 1, + price: { + amount: 10, + billingUnits: 1, + billingMethod: "usage_based", + interval: "month", + }, + }), + item({ + featureId: executions.id, + included: 50000, + reset: { interval: "month" }, + }), + ], +}); + +export const professional = plan({ + id: "professional", + name: "Professional", + price: { + amount: 40, + interval: "month", + }, + items: [ + item({ + featureId: seats.id, + included: 1, + price: { + amount: 40, + billingUnits: 1, + billingMethod: "usage_based", + interval: "month", + }, + }), + item({ + featureId: executions.id, + included: 100000, + reset: { interval: "month" }, + }), + ], +}); + +// Overage add-on +export const executionTopUp = plan({ + id: "execution-top-up", + name: "Execution Top-Up", + addOn: true, + items: [ + item({ + featureId: executions.id, + price: { + amount: 1, + billingUnits: 10000, + billingMethod: "prepaid", + interval: "month", + }, + }), + ], +}); diff --git a/apps/cloud/package.json b/apps/cloud/package.json index ac6e449d7b..b668804e18 100644 --- a/apps/cloud/package.json +++ b/apps/cloud/package.json @@ -39,6 +39,7 @@ "@tanstack/react-start": "catalog:", "@workos-inc/node": "^8.11.1", "agents": "^0.10.0", + "autumn-js": "^1.2.8", "drizzle-orm": "catalog:", "effect": "catalog:", "jose": "^5.6.3", diff --git a/apps/cloud/src/api.ts b/apps/cloud/src/api.ts index e667fdb0cd..aacbe0cca3 100644 --- a/apps/cloud/src/api.ts +++ b/apps/cloud/src/api.ts @@ -11,6 +11,8 @@ import { HttpServer, } from "@effect/platform"; import { Effect, Layer } from "effect"; +import { Autumn } from "autumn-js"; +import { autumnHandler } from "autumn-js/backend"; import { CoreExecutorApi } from "@executor/api"; import { CoreHandlers, ExecutorService, ExecutionEngineService } from "@executor/api/server"; @@ -155,6 +157,21 @@ const buildProtectedHandler = ( // --------------------------------------------------------------------------- const isAuthPath = (pathname: string): boolean => pathname.startsWith("/auth/"); +const isAutumnPath = (pathname: string): boolean => pathname.startsWith("/autumn/"); +const isExecutionPath = (pathname: string): boolean => + pathname === "/executions" || /^\/executions\/[^/]+\/resume$/.test(pathname); + +// --------------------------------------------------------------------------- +// Autumn billing — lazy-initialized SDK for fire-and-forget tracking +// --------------------------------------------------------------------------- + +let _autumn: Autumn | null = null; +const getAutumn = () => { + if (!_autumn && server.AUTUMN_SECRET_KEY) { + _autumn = new Autumn({ secretKey: server.AUTUMN_SECRET_KEY }); + } + return _autumn; +}; /** * Resolve the user's organization for executor creation. Reads from the @@ -170,9 +187,67 @@ const lookupOrgForRequest = (request: Request) => return yield* users.use((s) => s.getOrganization(result.organizationId!)); }); +// --------------------------------------------------------------------------- +// Autumn billing proxy — authenticates the session, then forwards to Autumn +// --------------------------------------------------------------------------- + +const handleAutumnRequest = async (request: Request): Promise => { + const program = Effect.gen(function* () { + const workos = yield* WorkOSAuth; + const result = yield* workos.authenticateRequest(request); + + if (!result || !result.organizationId) { + return Response.json({ error: "Unauthorized" }, { status: 401 }); + } + + const url = new URL(request.url); + const body = + request.method !== "GET" && request.method !== "HEAD" + ? yield* Effect.promise(() => request.json()) + : undefined; + + const { statusCode, response } = yield* Effect.promise(() => + autumnHandler({ + request: { + url: url.pathname, + method: request.method, + body, + }, + customerId: result.organizationId, + customerData: { + name: result.email, + email: result.email, + }, + clientOptions: { + secretKey: server.AUTUMN_SECRET_KEY, + }, + pathPrefix: "/autumn", + }), + ); + + if (statusCode >= 400) { + console.error("[autumn] upstream error:", statusCode, response); + return Response.json({ error: "Billing request failed" }, { status: statusCode }); + } + + return Response.json(response, { status: statusCode }); + }); + + return Effect.runPromise(program.pipe(Effect.provide(SharedServices), Effect.scoped)).catch( + (err) => { + console.error("[autumn] request failed:", err instanceof Error ? err.stack : err); + return Response.json({ error: "Internal server error" }, { status: 500 }); + }, + ); +}; + export const handleApiRequest = async (request: Request): Promise => { const pathname = new URL(request.url).pathname; + if (isAutumnPath(pathname)) { + return handleAutumnRequest(request); + } + if (isAuthPath(pathname)) { const handler = createNonProtectedHandler(); try { @@ -190,7 +265,8 @@ export const handleApiRequest = async (request: Request): Promise => { const codeExecutor = makeDynamicWorkerExecutor({ loader: env.LOADER }); const handler = yield* buildProtectedHandler(org.id, org.name, codeExecutor); - return yield* Effect.promise(() => handler.handler(request)); + const response = yield* Effect.promise(() => handler.handler(request)); + return { response, orgId: org.id }; }); const result = await Effect.runPromise( @@ -203,7 +279,24 @@ export const handleApiRequest = async (request: Request): Promise => { { status: 403 }, ); } - return result; + + // Fire-and-forget: track execution usage + if (isExecutionPath(pathname) && result.response.ok) { + const autumn = getAutumn(); + if (autumn) { + autumn + .track({ + customerId: result.orgId, + featureId: "executions", + value: 1, + }) + .catch((err) => { + console.error("[billing] track failed:", err); + }); + } + } + + return result.response; } catch (err) { console.error("[api] request failed:", err instanceof Error ? err.stack : err); return Response.json( diff --git a/apps/cloud/src/env.ts b/apps/cloud/src/env.ts index 1067041439..196d373ca6 100644 --- a/apps/cloud/src/env.ts +++ b/apps/cloud/src/env.ts @@ -11,6 +11,7 @@ const serverShape = { WORKOS_CLIENT_ID: Env.string("WORKOS_CLIENT_ID"), WORKOS_COOKIE_PASSWORD: Env.string("WORKOS_COOKIE_PASSWORD"), VITE_PUBLIC_SITE_URL: Env.stringOr("VITE_PUBLIC_SITE_URL", ""), + AUTUMN_SECRET_KEY: Env.stringOr("AUTUMN_SECRET_KEY", ""), }; type SharedEnv = Readonly<{ @@ -25,6 +26,7 @@ type ServerEnv = SharedEnv & WORKOS_CLIENT_ID: string; WORKOS_COOKIE_PASSWORD: string; VITE_PUBLIC_SITE_URL: string; + AUTUMN_SECRET_KEY: string; }>; type WebEnv = Readonly>; diff --git a/apps/cloud/src/routeTree.gen.ts b/apps/cloud/src/routeTree.gen.ts index 9845d53da3..2ec0acaa56 100644 --- a/apps/cloud/src/routeTree.gen.ts +++ b/apps/cloud/src/routeTree.gen.ts @@ -11,8 +11,10 @@ import { Route as rootRouteImport } from "./routes/__root"; import { Route as ToolsRouteImport } from "./routes/tools"; import { Route as SecretsRouteImport } from "./routes/secrets"; +import { Route as BillingRouteImport } from "./routes/billing"; import { Route as IndexRouteImport } from "./routes/index"; import { Route as SourcesNamespaceRouteImport } from "./routes/sources.$namespace"; +import { Route as BillingPlansRouteImport } from "./routes/billing_.plans"; import { Route as SourcesAddPluginKeyRouteImport } from "./routes/sources.add.$pluginKey"; const ToolsRoute = ToolsRouteImport.update({ @@ -25,6 +27,11 @@ const SecretsRoute = SecretsRouteImport.update({ path: "/secrets", getParentRoute: () => rootRouteImport, } as any); +const BillingRoute = BillingRouteImport.update({ + id: "/billing", + path: "/billing", + getParentRoute: () => rootRouteImport, +} as any); const IndexRoute = IndexRouteImport.update({ id: "/", path: "/", @@ -35,6 +42,11 @@ const SourcesNamespaceRoute = SourcesNamespaceRouteImport.update({ path: "/sources/$namespace", getParentRoute: () => rootRouteImport, } as any); +const BillingPlansRoute = BillingPlansRouteImport.update({ + id: "/billing_/plans", + path: "/billing/plans", + getParentRoute: () => rootRouteImport, +} as any); const SourcesAddPluginKeyRoute = SourcesAddPluginKeyRouteImport.update({ id: "/sources/add/$pluginKey", path: "/sources/add/$pluginKey", @@ -43,38 +55,68 @@ const SourcesAddPluginKeyRoute = SourcesAddPluginKeyRouteImport.update({ export interface FileRoutesByFullPath { "/": typeof IndexRoute; + "/billing": typeof BillingRoute; "/secrets": typeof SecretsRoute; "/tools": typeof ToolsRoute; + "/billing/plans": typeof BillingPlansRoute; "/sources/$namespace": typeof SourcesNamespaceRoute; "/sources/add/$pluginKey": typeof SourcesAddPluginKeyRoute; } export interface FileRoutesByTo { "/": typeof IndexRoute; + "/billing": typeof BillingRoute; "/secrets": typeof SecretsRoute; "/tools": typeof ToolsRoute; + "/billing/plans": typeof BillingPlansRoute; "/sources/$namespace": typeof SourcesNamespaceRoute; "/sources/add/$pluginKey": typeof SourcesAddPluginKeyRoute; } export interface FileRoutesById { __root__: typeof rootRouteImport; "/": typeof IndexRoute; + "/billing": typeof BillingRoute; "/secrets": typeof SecretsRoute; "/tools": typeof ToolsRoute; + "/billing_/plans": typeof BillingPlansRoute; "/sources/$namespace": typeof SourcesNamespaceRoute; "/sources/add/$pluginKey": typeof SourcesAddPluginKeyRoute; } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath; - fullPaths: "/" | "/secrets" | "/tools" | "/sources/$namespace" | "/sources/add/$pluginKey"; + fullPaths: + | "/" + | "/billing" + | "/secrets" + | "/tools" + | "/billing/plans" + | "/sources/$namespace" + | "/sources/add/$pluginKey"; fileRoutesByTo: FileRoutesByTo; - to: "/" | "/secrets" | "/tools" | "/sources/$namespace" | "/sources/add/$pluginKey"; - id: "__root__" | "/" | "/secrets" | "/tools" | "/sources/$namespace" | "/sources/add/$pluginKey"; + to: + | "/" + | "/billing" + | "/secrets" + | "/tools" + | "/billing/plans" + | "/sources/$namespace" + | "/sources/add/$pluginKey"; + id: + | "__root__" + | "/" + | "/billing" + | "/secrets" + | "/tools" + | "/billing_/plans" + | "/sources/$namespace" + | "/sources/add/$pluginKey"; fileRoutesById: FileRoutesById; } export interface RootRouteChildren { IndexRoute: typeof IndexRoute; + BillingRoute: typeof BillingRoute; SecretsRoute: typeof SecretsRoute; ToolsRoute: typeof ToolsRoute; + BillingPlansRoute: typeof BillingPlansRoute; SourcesNamespaceRoute: typeof SourcesNamespaceRoute; SourcesAddPluginKeyRoute: typeof SourcesAddPluginKeyRoute; } @@ -95,6 +137,13 @@ declare module "@tanstack/react-router" { preLoaderRoute: typeof SecretsRouteImport; parentRoute: typeof rootRouteImport; }; + "/billing": { + id: "/billing"; + path: "/billing"; + fullPath: "/billing"; + preLoaderRoute: typeof BillingRouteImport; + parentRoute: typeof rootRouteImport; + }; "/": { id: "/"; path: "/"; @@ -109,6 +158,13 @@ declare module "@tanstack/react-router" { preLoaderRoute: typeof SourcesNamespaceRouteImport; parentRoute: typeof rootRouteImport; }; + "/billing_/plans": { + id: "/billing_/plans"; + path: "/billing/plans"; + fullPath: "/billing/plans"; + preLoaderRoute: typeof BillingPlansRouteImport; + parentRoute: typeof rootRouteImport; + }; "/sources/add/$pluginKey": { id: "/sources/add/$pluginKey"; path: "/sources/add/$pluginKey"; @@ -121,8 +177,10 @@ declare module "@tanstack/react-router" { const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, + BillingRoute: BillingRoute, SecretsRoute: SecretsRoute, ToolsRoute: ToolsRoute, + BillingPlansRoute: BillingPlansRoute, SourcesNamespaceRoute: SourcesNamespaceRoute, SourcesAddPluginKeyRoute: SourcesAddPluginKeyRoute, }; diff --git a/apps/cloud/src/routes/__root.tsx b/apps/cloud/src/routes/__root.tsx index a4cdda18ed..544103a6b7 100644 --- a/apps/cloud/src/routes/__root.tsx +++ b/apps/cloud/src/routes/__root.tsx @@ -1,5 +1,6 @@ import React from "react"; import { HeadContent, Outlet, Scripts, createRootRoute } from "@tanstack/react-router"; +import { AutumnProvider } from "autumn-js/react"; import { ExecutorProvider } from "@executor/react/api/provider"; import { AuthProvider, useAuth } from "../web/auth"; import { LoginPage } from "../web/pages/login"; @@ -22,6 +23,7 @@ export const Route = createRootRoute({ }, { rel: "stylesheet", href: appCss }, ], + scripts: import.meta.env.DEV ? [{ src: "https://ui.sh/ui-picker.js" }] : [], }), component: RootComponent, shellComponent: RootDocument, @@ -65,8 +67,10 @@ function AuthGate() { } return ( - - - + + + + + ); } diff --git a/apps/cloud/src/routes/billing.tsx b/apps/cloud/src/routes/billing.tsx new file mode 100644 index 0000000000..b1a8d8a043 --- /dev/null +++ b/apps/cloud/src/routes/billing.tsx @@ -0,0 +1,141 @@ +import { createFileRoute, Link } from "@tanstack/react-router"; +import { useCustomer, useListPlans } from "autumn-js/react"; + +export const Route = createFileRoute("/billing")({ + component: BillingPage, +}); + +const PLAN_TAGLINES: Record = { + free: "For trying things out", + hobby: "For individuals and small teams", + professional: "For teams that need more", +}; + +function BillingPage() { + const { data: customer, openCustomerPortal, isLoading: customerLoading } = useCustomer(); + const { data: plans, isLoading: plansLoading } = useListPlans(); + + if (customerLoading || plansLoading) { + return ( +
+
+
+
+
+
+
+
+ ); + } + + // Find current plan via customerEligibility from useListPlans + const activePlan = (plans ?? []).find( + (p) => p.customerEligibility?.status === "active" && p.id !== "free", + ); + const scheduledPlan = (plans ?? []).find( + (p) => p.customerEligibility?.status === "scheduled" && p.id !== "free", + ); + const isCanceling = activePlan?.customerEligibility?.canceling ?? false; + const isSwitching = isCanceling && scheduledPlan != null; + + const displayPlan = isSwitching ? scheduledPlan : activePlan; + const planId = displayPlan?.id ?? "free"; + const planName = displayPlan?.name ?? "Free"; + const tagline = PLAN_TAGLINES[planId] ?? ""; + + const sub = customer?.subscriptions?.find( + (s) => + s.planId === (activePlan?.id ?? "free") && (s.status === "active" || s.status === "trialing"), + ); + + const executions = customer?.balances?.executions; + + return ( +
+
+

+ Billing +

+ + {/* Current plan */} +
+
+
+

+ {planName} +

+ {isSwitching && ( + + Switching + + )} + {isCanceling && !isSwitching && ( + + Canceling + + )} +
+

+ {isSwitching && sub?.currentPeriodEnd + ? `Starts ${new Date(sub.currentPeriodEnd).toLocaleDateString(undefined, { month: "long", day: "numeric", year: "numeric" })}` + : isCanceling && sub?.currentPeriodEnd + ? `Access until ${new Date(sub.currentPeriodEnd).toLocaleDateString(undefined, { month: "long", day: "numeric", year: "numeric" })}` + : sub?.currentPeriodEnd + ? `Renews ${new Date(sub.currentPeriodEnd).toLocaleDateString(undefined, { month: "long", day: "numeric", year: "numeric" })}` + : tagline} +

+
+
+ {activePlan && !isCanceling && ( + + )} + + Manage + +
+
+ + {/* Divider */} +
+ + {/* Usage */} + {executions && ( +
+
+

Executions

+

+ {executions.usage.toLocaleString()} + + {" / "} + {executions.granted.toLocaleString()} this month + +

+
+ {!executions.unlimited && executions.granted > 0 && ( +
+
+
+ )} +
+ )} +
+
+ ); +} diff --git a/apps/cloud/src/routes/billing_.plans.tsx b/apps/cloud/src/routes/billing_.plans.tsx new file mode 100644 index 0000000000..f9b304a18a --- /dev/null +++ b/apps/cloud/src/routes/billing_.plans.tsx @@ -0,0 +1,213 @@ +import { useState } from "react"; +import { createFileRoute, Link } from "@tanstack/react-router"; +import { useCustomer, useListPlans } from "autumn-js/react"; + +export const Route = createFileRoute("/billing_/plans")({ + component: PlansPage, +}); + +const PLAN_META: Record = { + hobby: { + tagline: "For individuals and small teams", + features: [ + "50,000 executions / month", + "Up to 5 seats", + "60s execution timeout", + "Unlimited sources", + "$1 per 10,000 extra executions", + ], + }, + professional: { + tagline: "For teams that need more", + features: [ + "100,000 executions / month", + "Unlimited seats", + "5 minute execution timeout", + "Unlimited sources", + "$1 per 10,000 extra executions", + ], + }, +}; + +const ACTION_LABELS: Record = { + activate: "Subscribe", + upgrade: "Upgrade", + downgrade: "Downgrade", + none: "Current plan", + purchase: "Purchase", +}; + +function PlansPage() { + const { attach, openCustomerPortal, isLoading: customerLoading } = useCustomer(); + const { data: plans, isLoading: plansLoading, isFetching } = useListPlans(); + const [loadingPlan, setLoadingPlan] = useState(null); + + const isLoading = customerLoading || plansLoading; + + const paidPlans = (plans ?? []).filter((p) => p.id === "hobby" || p.id === "professional"); + + return ( +
+
+
+ + + + + Billing + +

+ Choose a plan +

+

+ Pick the plan that works for you. Upgrade or downgrade anytime. +

+
+ + {isLoading ? ( +
+
+
+
+ ) : ( +
+ {paidPlans.map((plan) => { + const meta = PLAN_META[plan.id]; + if (!meta) return null; + + const eligibility = plan.customerEligibility; + const action = eligibility?.attachAction ?? "activate"; + const status = eligibility?.status; + const isCanceling = eligibility?.canceling ?? false; + const isCurrent = status === "active" && !isCanceling; + const isScheduled = status === "scheduled"; + const isActionable = action !== "none"; + const label = isCanceling ? "Resume" : (ACTION_LABELS[action] ?? "Select"); + const isUpgradeAction = action === "upgrade" || action === "activate"; + + return ( +
+
+

+ {plan.name} +

+ {isCurrent && ( + + Your plan + + )} + {isCanceling && ( + + Canceling + + )} + {isScheduled && ( + + Scheduled + + )} +
+

{meta.tagline}

+ +
+ + ${plan.price?.amount ?? 0} + + {plan.price?.interval && ( + + USD / seat / {plan.price.interval} + + )} +
+ +
+ {(isCurrent && !isCanceling) || isScheduled ? ( +
+ {isCurrent ? "Current plan" : "Scheduled"} +
+ ) : isCanceling ? ( + + ) : ( + + )} +
+ +
    + {meta.features.map((f) => ( +
  • + + + + {f} +
  • + ))} +
+
+ ); + })} +
+ )} +
+
+ ); +} diff --git a/apps/cloud/src/services/autumn.ts b/apps/cloud/src/services/autumn.ts new file mode 100644 index 0000000000..eb07fb47ad --- /dev/null +++ b/apps/cloud/src/services/autumn.ts @@ -0,0 +1,55 @@ +// --------------------------------------------------------------------------- +// Autumn billing service — wraps the autumn-js SDK with Effect +// --------------------------------------------------------------------------- + +import { Autumn as AutumnSDK } from "autumn-js"; +import { Context, Data, Effect, Layer, Config, Redacted } from "effect"; + +// --------------------------------------------------------------------------- +// Errors +// --------------------------------------------------------------------------- + +export class AutumnError extends Data.TaggedError("AutumnError")<{ + cause: unknown; +}> {} + +export class AutumnInstantiationError extends Data.TaggedError("AutumnInstantiationError")<{ + cause: unknown; +}> {} + +// --------------------------------------------------------------------------- +// Service interface +// --------------------------------------------------------------------------- + +export type IAutumnService = Readonly<{ + client: AutumnSDK; + use: (fn: (client: AutumnSDK) => Promise) => Effect.Effect; +}>; + +// --------------------------------------------------------------------------- +// Implementation +// --------------------------------------------------------------------------- + +const make = Effect.gen(function* () { + const secretKey = yield* Config.redacted("AUTUMN_SECRET_KEY"); + + const client = yield* Effect.try({ + try: () => new AutumnSDK({ secretKey: Redacted.value(secretKey) }), + catch: (cause) => new AutumnInstantiationError({ cause }), + }); + + const use = (fn: (client: AutumnSDK) => Promise) => + Effect.tryPromise({ + try: () => fn(client), + catch: (cause) => new AutumnError({ cause }), + }).pipe(Effect.withSpan(`autumn.${fn.name ?? "use"}`)); + + return { client, use } satisfies IAutumnService; +}); + +export class AutumnService extends Context.Tag("@executor/cloud/AutumnService")< + AutumnService, + IAutumnService +>() { + static Default = Layer.effect(this, make).pipe(Layer.annotateSpans({ module: "AutumnService" })); +} diff --git a/apps/cloud/src/web/shell.tsx b/apps/cloud/src/web/shell.tsx index 58818b0563..f30d23c61e 100644 --- a/apps/cloud/src/web/shell.tsx +++ b/apps/cloud/src/web/shell.tsx @@ -135,6 +135,7 @@ function UserFooter() { function SidebarContent(props: { pathname: string; onNavigate?: () => void; showBrand?: boolean }) { const isHome = props.pathname === "/"; const isSecrets = props.pathname === "/secrets"; + const isBilling = props.pathname === "/billing" || props.pathname.startsWith("/billing/"); return ( <> @@ -149,6 +150,7 @@ function SidebarContent(props: { pathname: string; onNavigate?: () => void; show