forked from molecule-ai/molecule-core
Rename "Starter" → "Team", update tagline + pricing page hero copy to lead with flat-rate per-org positioning — deliberate wedge against Cursor/Windsurf per-seat pricing ($40/seat vs $29/org). PMM decision: Issue #1833. Approved by Marketing Lead 2026-04-24. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
164 lines
5.8 KiB
TypeScript
164 lines
5.8 KiB
TypeScript
/**
|
|
* 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.
|
|
//
|
|
// Flat-rate positioning (Issue #1833): "starter" and "pro" are flat-rate
|
|
// per-org, not per-seat. This is a deliberate wedge against Cursor/Windsurf
|
|
// ($40/seat) — at 5 engineers the Team tier is 28% cheaper.
|
|
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: "Team",
|
|
tagline: "Flat-rate for teams — one price, no per-seat fees",
|
|
price: "$29/month",
|
|
features: [
|
|
"10 workspaces",
|
|
"All runtimes + plugins",
|
|
"Private Upstash Redis namespace",
|
|
"Email support (48h)",
|
|
"5M LLM tokens / month included",
|
|
"No per-seat pricing",
|
|
],
|
|
ctaLabel: "Upgrade to Team",
|
|
highlighted: true,
|
|
},
|
|
{
|
|
id: "pro",
|
|
name: "Growth",
|
|
tagline: "Flat-rate 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",
|
|
"No per-seat pricing",
|
|
"Usage-based overage billing",
|
|
],
|
|
ctaLabel: "Upgrade to Growth",
|
|
},
|
|
];
|
|
|
|
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> {
|
|
// On success, send the user to /orgs so they can watch their newly-
|
|
// paid org move from awaiting_payment → provisioning → running.
|
|
// Landing back on /pricing (the old default) left people staring at
|
|
// plan cards with no indication anything happened.
|
|
// On cancel, keep them on the current page so they can retry.
|
|
const origin = typeof window !== "undefined" ? window.location.origin : "";
|
|
const cancelBase =
|
|
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: `${origin}/orgs?checkout=success`,
|
|
cancel_url: `${cancelBase}?checkout=cancel`,
|
|
}),
|
|
});
|
|
if (!res.ok) {
|
|
// Never embed res.text() in the thrown error — the response body
|
|
// may contain Stripe API error detail (e.g. invalid key, card decline
|
|
// message, raw Stripe envelope) that should not reach the client.
|
|
const detail = await res.text();
|
|
console.error(`[billing] checkout ${res.status}: ${detail}`);
|
|
throw new Error(`checkout failed (${res.status})`);
|
|
}
|
|
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) {
|
|
// Never embed res.text() in the thrown error — the response body
|
|
// may contain Stripe API error detail (e.g. invalid key, card decline
|
|
// message, raw Stripe envelope) that should not reach the client.
|
|
const detail = await res.text();
|
|
console.error(`[billing] portal ${res.status}: ${detail}`);
|
|
throw new Error(`portal failed (${res.status})`);
|
|
}
|
|
const data = (await res.json()) as { url: string };
|
|
return data.url;
|
|
}
|