import { log } from "@clack/prompts" import { defineCommand } from "../lib/command" import { globalOptions, globalOptionsSchema } from "../lib/global-options" import { setTimeout } from "node:timers/promises" import open from "open" import z from "zod" import { apiClient } from "../lib/api-client" import { ORG_OPT, orgOptsSchema, output, requireSession, sessionIntro, useOrganization, ORG_OPTION_DESCRIPTIONS, } from "../utils" import { accent, dimmed } from "../lib/io" import { isJsonOutput } from "../lib/json-output" import { clearSpinner, startSpinner, withSpinner } from "../lib/spinner" import { blue } from "ansis" /** Paid plans available through the upgrade command. */ const upgradePlans = ["pro", "enterprise"] as const /** Arguments accepted by the organization upgrade command. */ const upgradeOrgOptsSchema = orgOptsSchema.extend({ plan: z.enum(upgradePlans).optional(), }) type ResolvedOrganization = Awaited> export const orgUpgradeCommand = defineCommand({ name: "upgrade", description: "Upgrade an organization plan", options: { ...globalOptions, ...ORG_OPT, plan: { type: "string", short: "p", }, }, optionDescriptions: { ...ORG_OPTION_DESCRIPTIONS, plan: `Plan to upgrade to (${upgradePlans.join(", ")})`, }, schema: globalOptionsSchema.extend(upgradeOrgOptsSchema.shape), run: upgradeOrganization, }) /** * Starts Checkout for Pro or prints Enterprise contact details. * * @param opts - Validated organization and target plan options. * @param options - Presentation options for callers composing CLI flows. * @param options.printSessionIntro - Whether to print the authenticated user. */ export async function upgradeOrganization( opts: z.infer, options: { printSessionIntro?: boolean } = {}, ) { const session = await requireSession("upgrade an organization") if (options.printSessionIntro ?? true) { output.normal(() => sessionIntro(session)) } const selectedOrg = await useOrganization(opts) const org = await withSpinner("Fetching billing details", () => apiClient.org.get({ organizationId: selectedOrg.id }), ) const currentPlan = org.plan if ( (opts.plan ?? (currentPlan === "free" ? "pro" : "enterprise")) === "enterprise" ) { printEnterpriseContact(org) return } if (currentPlan === "enterprise") { output .normal(() => log.info( `You're already on Enterprise. Email ${accent`hello@automate.ax`} for plan changes.`, ), ) .json({ status: "already_enterprise", organization: org }) return } if (currentPlan === "pro") { output .normal(() => log.info( `You're already on Pro. Run ${accent`automate org billing`} to manage billing.`, ), ) .json({ status: "already_pro", organization: org }) return } startSpinner("Opening checkout") const { url } = await apiClient.org.createCheckout({ organizationId: org.id, }) clearSpinner() output .normal(() => { log.success("Checkout ready") log.message( `${dimmed`URL:`} ${blue.underline.link(url, "Open checkout")}`, { symbol: blue`↗`, }, ) }) .json({ url, organization: org, plan: "pro" }) if (isJsonOutput()) return await open(url, { wait: false }).catch(() => null) startSpinner("Waiting for upgrade") await pollForSubscriptionPlan(org.id, "pro") clearSpinner() log.success(`Upgraded ${accent(org.name)} to Pro`) } /** * Prints and opens the Enterprise upgrade mailto link. * * @param org - Organization whose details should prefill the message. */ export function printEnterpriseContact(org: ResolvedOrganization) { const mailto = enterpriseMailto(org) output .normal(() => { log.info( [ "Enterprise plans are custom.", `Email ${accent`hello@automate.ax`} to upgrade ${accent(org.name)}.`, ].join("\n"), ) log.message(blue.underline.link(mailto, `hello@automate.ax`), { symbol: blue`↗`, }) }) .json({ url: mailto, email: "hello@automate.ax", organization: org }) if (isJsonOutput()) return void open(mailto, { wait: false }).catch(() => null) } /** * Builds an Enterprise sales email link for an organization. * * @param org - Organization whose details should prefill the message. */ function enterpriseMailto(org: ResolvedOrganization) { return `mailto:hello@automate.ax?${new URLSearchParams({ body: `Organization: ${org.name} (${org.id})`, subject: "Automate.ax Enterprise upgrade", }).toString()}` } /** * Polls until Stripe webhooks sync the expected organization subscription. * * @param organizationId - Organization whose subscription should be checked. * @param plan - Plan expected after checkout completes. */ async function pollForSubscriptionPlan(organizationId: string, plan: string) { while (true) { await setTimeout(2000) if ( (await apiClient.org.get({ organizationId })).activeSubscription?.plan === plan ) { return } } }