molecule-core/canvas/src/lib/billing.ts
Molecule AI Marketing Lead de19cf9bae fix(canvas): apply flat-rate pricing copy for Phase 34 launch (Issue #1833)
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>
2026-04-24 17:54:23 +00:00

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;
}