diff --git a/canvas/src/app/pricing/page.tsx b/canvas/src/app/pricing/page.tsx new file mode 100644 index 00000000..061a7e60 --- /dev/null +++ b/canvas/src/app/pricing/page.tsx @@ -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 ( +
+
+

+ Pricing +

+

+ 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. +

+
+ + + +
+

Questions?

+

+ We publish the{" "} + + full source on GitHub + + {" "}— if something's ambiguous, file an issue or{" "} + + email support + + . +

+

+ Prices shown in USD. Enterprise / self-hosted licensing available — contact us. +

+
+ + +
+ ); +} diff --git a/canvas/src/components/PricingTable.tsx b/canvas/src/components/PricingTable.tsx new file mode 100644 index 00000000..c7898b11 --- /dev/null +++ b/canvas/src/components/PricingTable.tsx @@ -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(null); + const [loadingPlan, setLoadingPlan] = useState(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 (.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, slug); + window.location.href = result.url; + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setLoadingPlan(null); + } + }; + + return ( +
+ {error && ( +
+ {error} +
+ )} +
+ {plans.map((plan) => ( + handleClick(plan)} + /> + ))} +
+
+ ); +} + +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 ( +
+ {plan.highlighted && ( + + Most popular + + )} +

+ {plan.name} +

+

{plan.tagline}

+

{plan.price}

+
    + {plan.features.map((f) => ( +
  • + + ✓ + + {f} +
  • + ))} +
+ +
+ ); +} + diff --git a/canvas/src/components/__tests__/PricingTable.test.tsx b/canvas/src/components/__tests__/PricingTable.test.tsx new file mode 100644 index 00000000..af5faec0 --- /dev/null +++ b/canvas/src/components/__tests__/PricingTable.test.tsx @@ -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("@/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; +const mockedRedirectToLogin = redirectToLogin as ReturnType; +const mockedStartCheckout = startCheckout as ReturnType; +const mockedGetTenantSlug = getTenantSlug as ReturnType; + +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(); + 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(); + const badges = screen.getAllByText("Most popular"); + expect(badges.length).toBe(1); + }); + + it("Free CTA redirects to signup without any session probe", () => { + render(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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); + }); + }); +}); diff --git a/canvas/src/lib/__tests__/billing.test.ts b/canvas/src/lib/__tests__/billing.test.ts new file mode 100644 index 00000000..d4f4cd28 --- /dev/null +++ b/canvas/src/lib/__tests__/billing.test.ts @@ -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).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).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).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).mockResolvedValue({ + ok: true, + json: async () => ({ url: "https://checkout.stripe.com/x" }), + }); + await startCheckout("starter", "acme"); + const body = JSON.parse( + (global.fetch as ReturnType).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).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).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).mockResolvedValue({ + ok: false, + status: 500, + text: async () => "boom", + json: async () => ({}), + }); + await expect(openBillingPortal("acme")).rejects.toThrow(/500/); + }); +}); diff --git a/canvas/src/lib/billing.ts b/canvas/src/lib/billing.ts new file mode 100644 index 00000000..31e79db8 --- /dev/null +++ b/canvas/src/lib/billing.ts @@ -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, + orgSlug: string, +): Promise { + 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 { + 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; +}