From 0d4af085cf0470e355362e0c72ed2f10160bfa24 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Fri, 10 Apr 2026 01:09:54 -0700 Subject: [PATCH 1/6] feat(cloud): add Autumn billing integration with tiered pricing Integrate Autumn as the billing/entitlements layer over Stripe. Three tiers: Free (5k executions/mo), Hobby ($10/seat, 50k), and Professional ($40/seat, 100k) with $1/10k overage top-ups. Fire-and-forget execution tracking via the Autumn SDK. --- apps/cloud/@useautumn-sdk.d.ts | 16 ++ apps/cloud/autumn.config.ts | 100 ++++++++++ apps/cloud/package.json | 1 + apps/cloud/src/api.ts | 95 ++++++++- apps/cloud/src/env.ts | 2 + apps/cloud/src/routeTree.gen.ts | 242 ++++++++++++++--------- apps/cloud/src/routes/__root.tsx | 12 +- apps/cloud/src/routes/billing.tsx | 127 ++++++++++++ apps/cloud/src/routes/billing_.plans.tsx | 193 ++++++++++++++++++ apps/cloud/src/services/autumn.ts | 61 ++++++ apps/cloud/src/web/shell.tsx | 2 + autumn.config.ts | 100 ++++++++++ bun.lock | 15 +- 13 files changed, 868 insertions(+), 98 deletions(-) create mode 100644 apps/cloud/@useautumn-sdk.d.ts create mode 100644 apps/cloud/autumn.config.ts create mode 100644 apps/cloud/src/routes/billing.tsx create mode 100644 apps/cloud/src/routes/billing_.plans.tsx create mode 100644 apps/cloud/src/services/autumn.ts create mode 100644 autumn.config.ts diff --git a/apps/cloud/@useautumn-sdk.d.ts b/apps/cloud/@useautumn-sdk.d.ts new file mode 100644 index 0000000000..eb8922958a --- /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..4fbc2821b8 --- /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..4b7cd1618b 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", + }), + ); + + 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,22 @@ 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..f2ac7554c4 100644 --- a/apps/cloud/src/routeTree.gen.ts +++ b/apps/cloud/src/routeTree.gen.ts @@ -8,134 +8,192 @@ // You should NOT make any changes in this file as it will be overwritten. // Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. -import { Route as rootRouteImport } from "./routes/__root"; -import { Route as ToolsRouteImport } from "./routes/tools"; -import { Route as SecretsRouteImport } from "./routes/secrets"; -import { Route as IndexRouteImport } from "./routes/index"; -import { Route as SourcesNamespaceRouteImport } from "./routes/sources.$namespace"; -import { Route as SourcesAddPluginKeyRouteImport } from "./routes/sources.add.$pluginKey"; +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({ - id: "/tools", - path: "/tools", + id: '/tools', + path: '/tools', getParentRoute: () => rootRouteImport, -} as any); +} as any) const SecretsRoute = SecretsRouteImport.update({ - id: "/secrets", - path: "/secrets", + id: '/secrets', + path: '/secrets', getParentRoute: () => rootRouteImport, -} as any); +} as any) +const BillingRoute = BillingRouteImport.update({ + id: '/billing', + path: '/billing', + getParentRoute: () => rootRouteImport, +} as any) const IndexRoute = IndexRouteImport.update({ - id: "/", - path: "/", + id: '/', + path: '/', getParentRoute: () => rootRouteImport, -} as any); +} as any) const SourcesNamespaceRoute = SourcesNamespaceRouteImport.update({ - id: "/sources/$namespace", - path: "/sources/$namespace", + id: '/sources/$namespace', + path: '/sources/$namespace', + getParentRoute: () => rootRouteImport, +} as any) +const BillingPlansRoute = BillingPlansRouteImport.update({ + id: '/billing_/plans', + path: '/billing/plans', getParentRoute: () => rootRouteImport, -} as any); +} as any) const SourcesAddPluginKeyRoute = SourcesAddPluginKeyRouteImport.update({ - id: "/sources/add/$pluginKey", - path: "/sources/add/$pluginKey", + id: '/sources/add/$pluginKey', + path: '/sources/add/$pluginKey', getParentRoute: () => rootRouteImport, -} as any); +} as any) export interface FileRoutesByFullPath { - "/": typeof IndexRoute; - "/secrets": typeof SecretsRoute; - "/tools": typeof ToolsRoute; - "/sources/$namespace": typeof SourcesNamespaceRoute; - "/sources/add/$pluginKey": typeof SourcesAddPluginKeyRoute; + '/': 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; - "/secrets": typeof SecretsRoute; - "/tools": typeof ToolsRoute; - "/sources/$namespace": typeof SourcesNamespaceRoute; - "/sources/add/$pluginKey": typeof SourcesAddPluginKeyRoute; + '/': 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; - "/secrets": typeof SecretsRoute; - "/tools": typeof ToolsRoute; - "/sources/$namespace": typeof SourcesNamespaceRoute; - "/sources/add/$pluginKey": typeof SourcesAddPluginKeyRoute; + __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"; - fileRoutesByTo: FileRoutesByTo; - to: "/" | "/secrets" | "/tools" | "/sources/$namespace" | "/sources/add/$pluginKey"; - id: "__root__" | "/" | "/secrets" | "/tools" | "/sources/$namespace" | "/sources/add/$pluginKey"; - fileRoutesById: FileRoutesById; + fileRoutesByFullPath: FileRoutesByFullPath + fullPaths: + | '/' + | '/billing' + | '/secrets' + | '/tools' + | '/billing/plans' + | '/sources/$namespace' + | '/sources/add/$pluginKey' + fileRoutesByTo: FileRoutesByTo + 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; - SecretsRoute: typeof SecretsRoute; - ToolsRoute: typeof ToolsRoute; - SourcesNamespaceRoute: typeof SourcesNamespaceRoute; - SourcesAddPluginKeyRoute: typeof SourcesAddPluginKeyRoute; + IndexRoute: typeof IndexRoute + BillingRoute: typeof BillingRoute + SecretsRoute: typeof SecretsRoute + ToolsRoute: typeof ToolsRoute + BillingPlansRoute: typeof BillingPlansRoute + SourcesNamespaceRoute: typeof SourcesNamespaceRoute + SourcesAddPluginKeyRoute: typeof SourcesAddPluginKeyRoute } -declare module "@tanstack/react-router" { +declare module '@tanstack/react-router' { interface FileRoutesByPath { - "/tools": { - id: "/tools"; - path: "/tools"; - fullPath: "/tools"; - preLoaderRoute: typeof ToolsRouteImport; - parentRoute: typeof rootRouteImport; - }; - "/secrets": { - id: "/secrets"; - path: "/secrets"; - fullPath: "/secrets"; - preLoaderRoute: typeof SecretsRouteImport; - parentRoute: typeof rootRouteImport; - }; - "/": { - id: "/"; - path: "/"; - fullPath: "/"; - preLoaderRoute: typeof IndexRouteImport; - parentRoute: typeof rootRouteImport; - }; - "/sources/$namespace": { - id: "/sources/$namespace"; - path: "/sources/$namespace"; - fullPath: "/sources/$namespace"; - preLoaderRoute: typeof SourcesNamespaceRouteImport; - parentRoute: typeof rootRouteImport; - }; - "/sources/add/$pluginKey": { - id: "/sources/add/$pluginKey"; - path: "/sources/add/$pluginKey"; - fullPath: "/sources/add/$pluginKey"; - preLoaderRoute: typeof SourcesAddPluginKeyRouteImport; - parentRoute: typeof rootRouteImport; - }; + '/tools': { + id: '/tools' + path: '/tools' + fullPath: '/tools' + preLoaderRoute: typeof ToolsRouteImport + parentRoute: typeof rootRouteImport + } + '/secrets': { + id: '/secrets' + path: '/secrets' + fullPath: '/secrets' + preLoaderRoute: typeof SecretsRouteImport + parentRoute: typeof rootRouteImport + } + '/billing': { + id: '/billing' + path: '/billing' + fullPath: '/billing' + preLoaderRoute: typeof BillingRouteImport + parentRoute: typeof rootRouteImport + } + '/': { + id: '/' + path: '/' + fullPath: '/' + preLoaderRoute: typeof IndexRouteImport + parentRoute: typeof rootRouteImport + } + '/sources/$namespace': { + id: '/sources/$namespace' + path: '/sources/$namespace' + fullPath: '/sources/$namespace' + 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' + fullPath: '/sources/add/$pluginKey' + preLoaderRoute: typeof SourcesAddPluginKeyRouteImport + parentRoute: typeof rootRouteImport + } } } const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, + BillingRoute: BillingRoute, SecretsRoute: SecretsRoute, ToolsRoute: ToolsRoute, + BillingPlansRoute: BillingPlansRoute, SourcesNamespaceRoute: SourcesNamespaceRoute, SourcesAddPluginKeyRoute: SourcesAddPluginKeyRoute, -}; +} export const routeTree = rootRouteImport ._addFileChildren(rootRouteChildren) - ._addFileTypes(); + ._addFileTypes() -import type { getRouter } from "./router.tsx"; -import type { startInstance } from "./start.ts"; -declare module "@tanstack/react-start" { +import type { getRouter } from './router.tsx' +import type { startInstance } from './start.ts' +declare module '@tanstack/react-start' { interface Register { - ssr: true; - router: Awaited>; - config: Awaited>; + ssr: true + router: Awaited> + config: Awaited> } } diff --git a/apps/cloud/src/routes/__root.tsx b/apps/cloud/src/routes/__root.tsx index a4cdda18ed..6d4ee75a16 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,9 @@ 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 +69,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..16a0a2b6dd --- /dev/null +++ b/apps/cloud/src/routes/billing.tsx @@ -0,0 +1,127 @@ +import { createFileRoute, Link } from "@tanstack/react-router"; +import { useCustomer } from "autumn-js/react"; + +export const Route = createFileRoute("/billing")({ + component: BillingPage, +}); + +function BillingPage() { + const { data: customer, openCustomerPortal, isLoading } = useCustomer(); + + if (isLoading) { + return ( +
+
+
+
+
+
+
+
+ ); + } + + const allSubs = customer?.subscriptions ?? []; + const scheduledSub = allSubs.find((s: any) => s.status === "scheduled" && s.planId !== "free"); + const activeSubs = allSubs.filter((s: any) => s.status === "active" || s.status === "trialing"); + const paidSubs = activeSubs.filter((s: any) => s.planId !== "free"); + const activePaid = paidSubs.find((s: any) => s.canceledAt == null); + const cancelingSub = paidSubs.find((s: any) => s.canceledAt != null); + const currentPlan = activePaid ?? cancelingSub; + const isCanceling = !activePaid && cancelingSub != null; + const isSwitching = isCanceling && scheduledSub != null; + const planId = activePaid?.planId ?? (isSwitching ? scheduledSub.planId : "free"); + const executions = customer?.balances?.executions; + + const planInfo: Record = { + free: { name: "Free", tagline: "For trying things out" }, + hobby: { name: "Hobby", tagline: "For individuals and small teams" }, + professional: { name: "Professional", tagline: "For teams that need more" }, + }; + const plan = planInfo[planId] ?? planInfo.free; + + return ( +
+
+

+ Billing +

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

+ {plan.name} +

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

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

+
+
+
+ {currentPlan && !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..56a2e8d4df --- /dev/null +++ b/apps/cloud/src/routes/billing_.plans.tsx @@ -0,0 +1,193 @@ +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..2c64f1159b --- /dev/null +++ b/apps/cloud/src/services/autumn.ts @@ -0,0 +1,61 @@ +// --------------------------------------------------------------------------- +// 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