Merge pull request #260 from Molecule-AI/feat/pricing-page

feat(canvas): /pricing route with plan selector + Stripe checkout
This commit is contained in:
Hongming Wang 2026-04-15 13:48:47 -07:00 committed by GitHub
commit 5940de61d8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 651 additions and 0 deletions

View File

@ -0,0 +1,78 @@
import { PricingTable } from "@/components/PricingTable";
/**
* /pricing static marketing + plan-selector route.
*
* Served from the same canvas deploy as the tenant UI and the apex
* landing page. Intentionally a server component so the initial HTML
* renders with full content for SEO; PricingTable is a client
* component that handles the CTA click + checkout POST.
*
* Uses the same dark theme as the canvas so the visual transition
* from landing pricing in-app experience stays cohesive.
*/
export const metadata = {
title: "Pricing — Molecule AI",
description:
"Free while you tinker, paid tiers for shipping production multi-agent organizations. Transparent usage-based overage pricing on Pro.",
};
export default function PricingPage() {
return (
<main className="min-h-screen bg-zinc-950 text-zinc-100">
<div className="mx-auto max-w-5xl px-6 pt-20 pb-8 text-center">
<h1 className="text-5xl font-bold tracking-tight text-white md:text-6xl">
Pricing
</h1>
<p className="mx-auto mt-4 max-w-2xl text-lg text-zinc-300">
Free while you tinker. Pay when you ship real agents to production.
Every tier includes the full runtime stack you upgrade for scale,
support, and dedicated infrastructure.
</p>
</div>
<PricingTable />
<section className="mx-auto mt-20 max-w-3xl px-6 text-center">
<h2 className="text-2xl font-semibold text-white">Questions?</h2>
<p className="mt-2 text-zinc-400">
We publish the{" "}
<a
href="https://github.com/Molecule-AI/molecule-monorepo"
className="text-blue-400 underline hover:text-blue-300"
>
full source on GitHub
</a>
{" "} if something's ambiguous, file an issue or{" "}
<a
href="mailto:support@moleculesai.app"
className="text-blue-400 underline hover:text-blue-300"
>
email support
</a>
.
</p>
<p className="mt-6 text-sm text-zinc-500">
Prices shown in USD. Enterprise / self-hosted licensing available contact us.
</p>
</section>
<footer className="mx-auto mt-20 max-w-5xl border-t border-zinc-800 px-6 py-6 text-center text-sm text-zinc-500">
<p>
© {new Date().getFullYear()} Molecule AI, Inc. ·{" "}
<a href="/legal/terms" className="hover:text-zinc-300">
Terms
</a>
{" "}·{" "}
<a href="/legal/privacy" className="hover:text-zinc-300">
Privacy
</a>
{" "}·{" "}
<a href="/legal/dpa" className="hover:text-zinc-300">
DPA
</a>
</p>
</footer>
</main>
);
}

View File

@ -0,0 +1,142 @@
"use client";
import { useState } from "react";
import { plans, startCheckout, type Plan, type PlanId } from "@/lib/billing";
import { fetchSession, redirectToLogin, type Session } from "@/lib/auth";
import { getTenantSlug } from "@/lib/tenant";
/**
* PricingTable renders the three plan cards and wires each CTA button
* through a dispatcher:
*
* Free kick to signup
* Anonymous + paid kick to signup (Stripe checkout after auth)
* Authed + paid POST /cp/billing/checkout and redirect
* Any network failure surface a simple error banner in-place
*
* Session is fetched lazily on first click rather than on mount so
* anonymous users can browse the pricing page without a probe request.
*/
export function PricingTable() {
const [error, setError] = useState<string | null>(null);
const [loadingPlan, setLoadingPlan] = useState<PlanId | null>(null);
const handleClick = async (plan: Plan) => {
setError(null);
if (plan.id === "free") {
redirectToLogin("sign-up");
return;
}
setLoadingPlan(plan.id);
try {
// Lazy session probe — we only need it when the user commits to
// a paid plan, not on page load.
let session: Session | null = null;
try {
session = await fetchSession();
} catch (e) {
// Network error probing /cp/auth/me is treated the same as
// anonymous here — a real 5xx from CP would also block a
// Stripe checkout, so bouncing to signup is the safe path.
session = null;
}
if (!session) {
redirectToLogin("sign-up");
return;
}
// Session.org_id is the WorkOS org id, not the slug — we need the
// slug for the checkout endpoint. The slug lives in the URL on
// tenant subdomains (<slug>.moleculesai.app), so we read it from
// the helper. Session without a tenant slug means the user is on
// the canvas apex and needs to pick an org first.
const slug = getTenantSlug();
if (!slug) {
setError("Open a specific org on its tenant subdomain to upgrade.");
return;
}
const result = await startCheckout(plan.id as Exclude<PlanId, "free">, slug);
window.location.href = result.url;
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setLoadingPlan(null);
}
};
return (
<div className="mx-auto max-w-6xl px-6">
{error && (
<div
role="alert"
className="mx-auto mb-6 max-w-3xl rounded border border-red-900 bg-red-950/40 p-4 text-sm text-red-200"
>
{error}
</div>
)}
<div className="grid gap-6 md:grid-cols-3">
{plans.map((plan) => (
<PlanCard
key={plan.id}
plan={plan}
loading={loadingPlan === plan.id}
onSelect={() => handleClick(plan)}
/>
))}
</div>
</div>
);
}
function PlanCard({
plan,
loading,
onSelect,
}: {
plan: Plan;
loading: boolean;
onSelect: () => void;
}) {
const ring = plan.highlighted
? "border-blue-600 ring-2 ring-blue-600/30"
: "border-zinc-800";
return (
<article
className={`flex flex-col rounded-lg border ${ring} bg-zinc-900/40 p-6`}
aria-labelledby={`plan-${plan.id}-name`}
>
{plan.highlighted && (
<span className="mb-3 inline-block rounded-full bg-blue-600/20 px-3 py-1 text-xs font-medium text-blue-300">
Most popular
</span>
)}
<h2 id={`plan-${plan.id}-name`} className="text-xl font-semibold text-white">
{plan.name}
</h2>
<p className="mt-1 text-sm text-zinc-400">{plan.tagline}</p>
<p className="mt-4 text-3xl font-bold text-white">{plan.price}</p>
<ul className="mt-6 flex-1 space-y-2 text-sm text-zinc-300">
{plan.features.map((f) => (
<li key={f} className="flex items-start">
<span className="mr-2 text-blue-400" aria-hidden>
</span>
{f}
</li>
))}
</ul>
<button
type="button"
onClick={onSelect}
disabled={loading}
className={`mt-6 rounded-lg px-4 py-3 text-sm font-medium ${
plan.highlighted
? "bg-blue-600 text-white hover:bg-blue-500 disabled:bg-blue-900"
: "border border-zinc-700 bg-zinc-900 text-zinc-100 hover:bg-zinc-800 disabled:opacity-50"
}`}
>
{loading ? "Opening checkout…" : plan.ctaLabel}
</button>
</article>
);
}

View File

@ -0,0 +1,166 @@
// @vitest-environment jsdom
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { render, screen, fireEvent, cleanup, waitFor } from "@testing-library/react";
import { PricingTable } from "../PricingTable";
// Module mocks — both auth and billing are network-touching, so the tests
// script their return values. redirectToLogin is captured so we can assert
// the "anonymous + paid plan" path bounces correctly.
vi.mock("@/lib/auth", () => ({
fetchSession: vi.fn(),
redirectToLogin: vi.fn(),
}));
vi.mock("@/lib/billing", async () => {
const actual = await vi.importActual<typeof import("@/lib/billing")>("@/lib/billing");
return {
...actual,
startCheckout: vi.fn(),
};
});
// getTenantSlug is host-derived; override per test.
vi.mock("@/lib/tenant", () => ({
getTenantSlug: vi.fn(() => "acme"),
}));
import { fetchSession, redirectToLogin } from "@/lib/auth";
import { startCheckout } from "@/lib/billing";
import { getTenantSlug } from "@/lib/tenant";
const mockedFetchSession = fetchSession as ReturnType<typeof vi.fn>;
const mockedRedirectToLogin = redirectToLogin as ReturnType<typeof vi.fn>;
const mockedStartCheckout = startCheckout as ReturnType<typeof vi.fn>;
const mockedGetTenantSlug = getTenantSlug as ReturnType<typeof vi.fn>;
beforeEach(() => {
vi.clearAllMocks();
mockedGetTenantSlug.mockReturnValue("acme");
// Replace window.location.href with a spy so we can verify redirect
// intent without actually navigating the jsdom window.
Object.defineProperty(window, "location", {
value: { href: "http://localhost:3000/pricing", origin: "http://localhost:3000", pathname: "/pricing" },
writable: true,
});
});
afterEach(() => {
cleanup();
});
describe("PricingTable", () => {
it("renders all three plans with their CTAs", () => {
render(<PricingTable />);
expect(screen.getByRole("heading", { name: "Free" })).toBeTruthy();
expect(screen.getByRole("heading", { name: "Starter" })).toBeTruthy();
expect(screen.getByRole("heading", { name: "Pro" })).toBeTruthy();
expect(screen.getByRole("button", { name: "Get started" })).toBeTruthy();
expect(screen.getByRole("button", { name: "Upgrade to Starter" })).toBeTruthy();
expect(screen.getByRole("button", { name: "Upgrade to Pro" })).toBeTruthy();
});
it("shows the 'Most popular' badge only on the starter card", () => {
render(<PricingTable />);
const badges = screen.getAllByText("Most popular");
expect(badges.length).toBe(1);
});
it("Free CTA redirects to signup without any session probe", () => {
render(<PricingTable />);
fireEvent.click(screen.getByRole("button", { name: "Get started" }));
expect(mockedRedirectToLogin).toHaveBeenCalledWith("sign-up");
expect(mockedFetchSession).not.toHaveBeenCalled();
expect(mockedStartCheckout).not.toHaveBeenCalled();
});
it("Paid CTA + anonymous → bounces to signup (no checkout call)", async () => {
mockedFetchSession.mockResolvedValue(null);
render(<PricingTable />);
fireEvent.click(screen.getByRole("button", { name: "Upgrade to Starter" }));
await waitFor(() => expect(mockedRedirectToLogin).toHaveBeenCalledWith("sign-up"));
expect(mockedStartCheckout).not.toHaveBeenCalled();
});
it("Paid CTA + authed → calls startCheckout and redirects to Stripe URL", async () => {
mockedFetchSession.mockResolvedValue({
user_id: "u1",
org_id: "org-acme",
email: "a@b.com",
});
mockedStartCheckout.mockResolvedValue({
id: "cs_test",
url: "https://checkout.stripe.com/pay/cs_test",
});
render(<PricingTable />);
fireEvent.click(screen.getByRole("button", { name: "Upgrade to Pro" }));
await waitFor(() =>
expect(mockedStartCheckout).toHaveBeenCalledWith("pro", "acme"),
);
await waitFor(() =>
expect(window.location.href).toBe("https://checkout.stripe.com/pay/cs_test"),
);
expect(mockedRedirectToLogin).not.toHaveBeenCalled();
});
it("Paid CTA + authed + no tenant slug → shows 'pick an org first' error", async () => {
mockedFetchSession.mockResolvedValue({
user_id: "u1",
org_id: "org-acme",
email: "a@b.com",
});
mockedGetTenantSlug.mockReturnValue("");
render(<PricingTable />);
fireEvent.click(screen.getByRole("button", { name: "Upgrade to Starter" }));
await waitFor(() => {
const alert = screen.getByRole("alert");
expect(alert.textContent).toContain("tenant subdomain");
});
expect(mockedStartCheckout).not.toHaveBeenCalled();
});
it("surfaces network errors from startCheckout in the error banner", async () => {
mockedFetchSession.mockResolvedValue({
user_id: "u1",
org_id: "org-acme",
email: "a@b.com",
});
mockedStartCheckout.mockRejectedValue(new Error("checkout: 500 boom"));
render(<PricingTable />);
fireEvent.click(screen.getByRole("button", { name: "Upgrade to Pro" }));
await waitFor(() => {
const alert = screen.getByRole("alert");
expect(alert.textContent).toContain("500");
});
});
it("treats fetchSession network errors as anonymous (fail-closed to signup)", async () => {
mockedFetchSession.mockRejectedValue(new Error("network down"));
render(<PricingTable />);
fireEvent.click(screen.getByRole("button", { name: "Upgrade to Starter" }));
await waitFor(() => expect(mockedRedirectToLogin).toHaveBeenCalledWith("sign-up"));
expect(mockedStartCheckout).not.toHaveBeenCalled();
});
it("disables the button while a checkout call is in flight", async () => {
mockedFetchSession.mockResolvedValue({
user_id: "u1",
org_id: "org-acme",
email: "a@b.com",
});
// Return a promise we never resolve so the button stays loading.
mockedStartCheckout.mockReturnValue(new Promise(() => {}));
render(<PricingTable />);
const button = screen.getByRole("button", { name: "Upgrade to Pro" });
fireEvent.click(button);
await waitFor(() => {
const loading = screen.getByRole("button", { name: /opening checkout/i });
expect((loading as HTMLButtonElement).disabled).toBe(true);
});
});
});

View File

@ -0,0 +1,124 @@
// @vitest-environment jsdom
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { plans, startCheckout, openBillingPortal } from "../billing";
const originalFetch = global.fetch;
beforeEach(() => {
// Each test installs its own fetch mock; restore in afterEach so a
// failing test doesn't leak into the next one.
global.fetch = vi.fn() as unknown as typeof fetch;
// jsdom's default location is http://localhost:3000/; anchor the
// return_to construction there so snapshot assertions are stable.
Object.defineProperty(window, "location", {
value: {
origin: "http://localhost:3000",
pathname: "/pricing",
href: "http://localhost:3000/pricing",
},
writable: true,
});
});
afterEach(() => {
global.fetch = originalFetch;
vi.restoreAllMocks();
});
describe("plans", () => {
it("defines three canonical tiers in free → starter → pro order", () => {
expect(plans.map((p) => p.id)).toEqual(["free", "starter", "pro"]);
});
it("marks starter as highlighted (most-popular card)", () => {
const starter = plans.find((p) => p.id === "starter");
expect(starter?.highlighted).toBe(true);
});
it("gives every plan a price, tagline, and at least one feature", () => {
for (const plan of plans) {
expect(plan.price).toBeTruthy();
expect(plan.tagline).toBeTruthy();
expect(plan.features.length).toBeGreaterThan(0);
expect(plan.ctaLabel).toBeTruthy();
}
});
});
describe("startCheckout", () => {
it("POSTs to /cp/billing/checkout with the expected payload shape", async () => {
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
ok: true,
json: async () => ({ id: "cs_test", url: "https://checkout.stripe.com/pay/cs_test" }),
});
const result = await startCheckout("pro", "acme");
expect(result.url).toBe("https://checkout.stripe.com/pay/cs_test");
const call = (global.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
const url = call[0] as string;
const init = call[1] as RequestInit;
expect(url).toContain("/cp/billing/checkout");
expect(init.method).toBe("POST");
expect(init.credentials).toBe("include");
const body = JSON.parse(init.body as string);
expect(body.org_slug).toBe("acme");
expect(body.plan).toBe("pro");
expect(body.success_url).toContain("checkout=success");
expect(body.cancel_url).toContain("checkout=cancel");
});
it("throws with the body text on non-2xx so the UI can surface it", async () => {
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
ok: false,
status: 402,
text: async () => "payment required",
json: async () => ({}),
});
await expect(startCheckout("starter", "acme")).rejects.toThrow(/402/);
await expect(startCheckout("starter", "acme")).rejects.toThrow(/payment required/);
});
it("uses current pathname for success/cancel URLs", async () => {
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
ok: true,
json: async () => ({ url: "https://checkout.stripe.com/x" }),
});
await startCheckout("starter", "acme");
const body = JSON.parse(
(global.fetch as ReturnType<typeof vi.fn>).mock.calls[0][1].body,
);
expect(body.success_url).toBe("http://localhost:3000/pricing?checkout=success");
expect(body.cancel_url).toBe("http://localhost:3000/pricing?checkout=cancel");
});
});
describe("openBillingPortal", () => {
it("POSTs to /cp/billing/portal and returns the URL", async () => {
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
ok: true,
json: async () => ({ url: "https://billing.stripe.com/session/xyz" }),
});
const url = await openBillingPortal("acme");
expect(url).toBe("https://billing.stripe.com/session/xyz");
const call = (global.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
expect(call[0]).toContain("/cp/billing/portal");
const body = JSON.parse((call[1] as RequestInit).body as string);
expect(body.org_slug).toBe("acme");
expect(body.return_url).toBe("http://localhost:3000/pricing");
});
it("throws on non-2xx", async () => {
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
ok: false,
status: 500,
text: async () => "boom",
json: async () => ({}),
});
await expect(openBillingPortal("acme")).rejects.toThrow(/500/);
});
});

141
canvas/src/lib/billing.ts Normal file
View File

@ -0,0 +1,141 @@
/**
* Canvas-side billing helper. Talks to the control plane's /cp/billing/*
* routes these exist on the `molecule-cp` app in prod and are mirrored
* via fly-replay from tenant subdomains. Dev requires a locally-running
* control plane on the same port as PLATFORM_URL or these calls 404.
*/
import { PLATFORM_URL } from "./api";
export type PlanId = "free" | "starter" | "pro";
/**
* Plan is the static metadata a pricing card needs to render. Kept in
* the frontend (not fetched from the API) because changing prices or
* feature lists requires a deploy anyway and most of the strings are
* marketing copy that belongs with the rest of the UI.
*/
export interface Plan {
id: PlanId;
name: string;
tagline: string;
/** Human-readable price, e.g. "$0" or "$29/month". Stored as a string
* so we don't accidentally leak per-tier pricing math to the client. */
price: string;
features: string[];
/** CTA button label varies per plan because free-tier is "Get started"
* and paid tiers are "Upgrade to Pro" etc. */
ctaLabel: string;
/** Visual flag for the "most popular" highlight on the middle card. */
highlighted?: boolean;
}
// plans is the canonical order shown on the pricing page: free → starter
// → pro. Change the order here + the rendered columns follow. Keeping
// this as a module-level const so tests can assert against a known list.
export const plans: Plan[] = [
{
id: "free",
name: "Free",
tagline: "For tinkering + personal projects",
price: "$0",
features: [
"3 workspaces",
"Claude Code, LangGraph, OpenClaw runtimes",
"Shared Redis + bounded storage",
"Community support",
],
ctaLabel: "Get started",
},
{
id: "starter",
name: "Starter",
tagline: "For small teams shipping real agents",
price: "$29/month",
features: [
"10 workspaces",
"All runtimes + plugins",
"Private Upstash Redis namespace",
"Email support (48h)",
"5M LLM tokens / month included",
],
ctaLabel: "Upgrade to Starter",
highlighted: true,
},
{
id: "pro",
name: "Pro",
tagline: "For production multi-agent orgs",
price: "$99/month",
features: [
"Unlimited workspaces",
"Dedicated Fly Machine per tenant",
"Cross-workspace A2A audit log",
"Priority support (24h)",
"25M LLM tokens / month included",
"Usage-based overage billing",
],
ctaLabel: "Upgrade to Pro",
},
];
export interface CheckoutResponse {
url: string;
id?: string;
}
/**
* startCheckout asks the control plane to open a Stripe Checkout session
* for the given org + plan, then returns the Stripe URL the caller
* should window.location.href to. success_url and cancel_url default
* to the current page with ?checkout=success / ?checkout=cancel query
* params so the pricing page can display a confirmation banner.
*
* Throws on non-2xx (caller surfaces the error the page renders a
* toast). Does NOT automatically redirect the browser; the caller
* decides when to navigate.
*/
export async function startCheckout(
plan: Exclude<PlanId, "free">,
orgSlug: string,
): Promise<CheckoutResponse> {
const returnBase =
typeof window !== "undefined" ? window.location.origin + window.location.pathname : "";
const res = await fetch(`${PLATFORM_URL}/cp/billing/checkout`, {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
org_slug: orgSlug,
plan,
success_url: `${returnBase}?checkout=success`,
cancel_url: `${returnBase}?checkout=cancel`,
}),
});
if (!res.ok) {
const text = await res.text();
throw new Error(`checkout: ${res.status} ${text}`);
}
return res.json();
}
/**
* openBillingPortal bounces the user to Stripe's hosted customer portal
* so they can update their card, cancel, or download invoices. Same
* error-handling contract as startCheckout.
*/
export async function openBillingPortal(orgSlug: string): Promise<string> {
const returnUrl =
typeof window !== "undefined" ? window.location.href : "";
const res = await fetch(`${PLATFORM_URL}/cp/billing/portal`, {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ org_slug: orgSlug, return_url: returnUrl }),
});
if (!res.ok) {
const text = await res.text();
throw new Error(`portal: ${res.status} ${text}`);
}
const data = (await res.json()) as { url: string };
return data.url;
}