diff --git a/CHANGELOG.md b/CHANGELOG.md index 83dfd84..125c282 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ All notable changes to `@gethookmyapp/cli` are documented here. +## 0.14.15 — 2026-08-13 + +### Changed + +- `hookmyapp billing upgrade` now changes the plan of an existing subscription in the terminal: it states what you'll be charged today and when your next bill lands, asks for confirmation, and applies the change. No browser, and no sign-in for a CLI-only user. Custom plans, a pending cancellation, and an already-scheduled plan change still open the Billing page (AIT-398). + ## 0.14.14 — 2026-08-12 ### Added diff --git a/package-lock.json b/package-lock.json index 7ddb61f..6b042b6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@gethookmyapp/cli", - "version": "0.14.12", + "version": "0.14.15", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@gethookmyapp/cli", - "version": "0.14.12", + "version": "0.14.15", "license": "MIT", "dependencies": { "@inquirer/prompts": "^7.0.0", diff --git a/package.json b/package.json index 2770b1f..1f2ce4c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@gethookmyapp/cli", - "version": "0.14.14", + "version": "0.14.15", "description": "HookMyApp CLI, No BS. Just go live.", "type": "module", "bin": { diff --git a/src/__tests__/billing.test.ts b/src/__tests__/billing.test.ts index 8d91297..52ea4ce 100644 --- a/src/__tests__/billing.test.ts +++ b/src/__tests__/billing.test.ts @@ -15,7 +15,7 @@ vi.mock('open', () => ({ })); // Mock @inquirer/prompts -vi.mock('@inquirer/prompts', () => ({ select: vi.fn() })); +vi.mock('@inquirer/prompts', () => ({ select: vi.fn(), confirm: vi.fn() })); // Mock workspace config vi.mock('../commands/workspace.js', () => ({ @@ -42,6 +42,7 @@ const WORKSPACE_ID = 'ws_TEST0070'; const ORG_PUBLIC_ID = 'org_abc12345'; const SUBSCRIPTION_PATH = `/organizations/${ORG_PUBLIC_ID}/billing/subscription`; const CHECKOUT_PATH = `/organizations/${ORG_PUBLIC_ID}/billing/checkout`; +const BILLING_BASE = `/organizations/${ORG_PUBLIC_ID}/billing`; const WORKSPACES = [{ id: WORKSPACE_ID, name: 'Acme', organizationPublicId: ORG_PUBLIC_ID }]; // Phase A backend DTO cleanup: planSlug + stripeSubscriptionId removed. @@ -267,6 +268,9 @@ describe('billing commands', () => { const PLANS_CATALOG = [ { slug: 'free', name: 'Launch', messages: 2000, priceInCents: 0, annualPriceInCents: 0 }, + // A second paid plan so the paid-tier path (which drops the plan the org + // is already on) still has something to offer. + { slug: 'starter', name: 'Build', messages: 600, priceInCents: 1200, annualPriceInCents: 12000 }, { slug: 'growth', name: 'Scale', messages: 1200, priceInCents: 2400, annualPriceInCents: 24000 }, ]; @@ -280,22 +284,49 @@ describe('billing commands', () => { }); } - it('opens the app Billing page (not /stripe/portal) when user has active subscription', async () => { - mockSubAndWorkspaces({ status: 'active', plan: { slug: 'growth', name: 'Scale' } }); + /** Paid tiers change plan in the terminal (AIT-398): preview → confirm → + * apply, no browser. Declines the confirmation so the assertion is about + * which path ran, not about applying a change. */ + async function runPaidPathDeclining(status: string): Promise { + const origTTY = process.stdout.isTTY; + const origStdinTTY = process.stdin.isTTY; + process.stdout.isTTY = true; + process.stdin.isTTY = true; + const inq = await import('@inquirer/prompts'); + vi.mocked(inq.select).mockResolvedValueOnce('starter' as never); + vi.mocked(inq.confirm).mockResolvedValueOnce(false as never); + mockedApiClient.mockImplementation(async (path: string) => { + if (path === SUBSCRIPTION_PATH) { + return { status, plan: { slug: 'growth', name: 'Scale' }, billingInterval: 'monthly' }; + } + if (path === '/workspaces') return WORKSPACES; + if (path === '/plans') return PLANS_CATALOG; + if (path === `${BILLING_BASE}/usage-tier/preview`) return { scheduled: true }; + throw new Error(`unexpected path: ${path}`); + }); - await billingUpgrade(); + try { + await billingUpgrade(); + } finally { + process.stdout.isTTY = origTTY; + process.stdin.isTTY = origStdinTTY; + } + } - expect(mockedOpen).toHaveBeenCalledWith('https://app.test/org/org_abc12345/billing'); + it('changes plan in the terminal when the user has an active subscription (no browser)', async () => { + await runPaidPathDeclining('active'); + + expect(mockedOpen).not.toHaveBeenCalled(); const paths = mockedApiClient.mock.calls.map((c) => c[0]); + expect(paths).toContain(`${BILLING_BASE}/usage-tier/preview`); expect(paths).not.toContain('/stripe/portal'); }); it('treats past_due on a paid plan as a subscriber', async () => { - mockSubAndWorkspaces({ status: 'past_due', plan: { slug: 'growth', name: 'Scale' } }); - - await billingUpgrade(); + await runPaidPathDeclining('past_due'); - expect(mockedOpen).toHaveBeenCalledWith('https://app.test/org/org_abc12345/billing'); + const paths = mockedApiClient.mock.calls.map((c) => c[0]); + expect(paths).toContain(`${BILLING_BASE}/usage-tier/preview`); }); it('rejects --json with UPGRADE_NO_JSON (no machine-readable form)', async () => { @@ -321,10 +352,12 @@ describe('billing commands', () => { }); it('prompts free user for plan + interval and opens checkout', async () => { - // Free-tier path is interactive: guarded by a TTY check. Stub isTTY so - // the prompt branch runs instead of the non-TTY rejection. + // Free-tier path is interactive: guarded by a TTY check on both streams. + // Stub them so the prompt branch runs instead of the non-TTY rejection. const origTTY = process.stdout.isTTY; + const origStdinTTY = process.stdin.isTTY; process.stdout.isTTY = true; + process.stdin.isTTY = true; const inq = await import('@inquirer/prompts'); vi.mocked(inq.select) .mockResolvedValueOnce('growth' as never) @@ -369,6 +402,7 @@ describe('billing commands', () => { await run; } finally { process.stdout.isTTY = origTTY; + process.stdin.isTTY = origStdinTTY; vi.useRealTimers(); } diff --git a/src/commands/__tests__/billing.test.ts b/src/commands/__tests__/billing.test.ts index ba807aa..94af3a8 100644 --- a/src/commands/__tests__/billing.test.ts +++ b/src/commands/__tests__/billing.test.ts @@ -31,15 +31,22 @@ vi.mock('open', () => ({ default: vi.fn(async () => undefined) })); // a hardcoded plan list. let selectCalls: Array<{ choices: unknown }> = []; let selectAnswers: unknown[] = []; +// The paid-tier path (AIT-398) confirms the billing effect before applying it; +// tests queue the y/N answer the same way they queue select answers. +let confirmAnswers: boolean[] = []; vi.mock('@inquirer/prompts', () => ({ select: vi.fn(async (args: { choices: unknown }) => { selectCalls.push(args); return selectAnswers.shift(); }), + confirm: vi.fn(async () => confirmAnswers.shift() ?? false), })); function queueSelectAnswers(...answers: unknown[]): void { selectAnswers = answers; } +function queueConfirmAnswers(...answers: boolean[]): void { + confirmAnswers = answers; +} function capturedSelectCalls(): Array<{ choices: unknown }> { return selectCalls; } @@ -134,47 +141,219 @@ describe('billingManage — opens the app Billing page (portal retired)', () => }); }); -describe('billingUpgrade — active subscription path (portal retired)', () => { +const CATALOG = [ + { slug: 'free', name: 'Launch', messages: 2000, priceInCents: 0, annualPriceInCents: 0 }, + { slug: 'starter', name: 'Build', messages: 30000, priceInCents: 1200, annualPriceInCents: 12000 }, + { slug: 'growth', name: 'Scale', messages: 100000, priceInCents: 2400, annualPriceInCents: 24000, popular: true }, + // Non-whole monthly price (AIT-391): $39.99, not the rounded-to-$40 that + // Math.round(priceInCents / 100) used to render. + { slug: 'pro', name: 'Business', messages: 250000, priceInCents: 3999, annualPriceInCents: 39000 }, +]; + +describe('billingUpgrade — paid tier changes plan in the terminal (AIT-398)', () => { + // An org on Build, billed monthly, period ending Sep 13. + const SUB = { + status: 'active', + plan: { slug: 'starter', name: 'Build', priceInCents: 1200, annualPriceInCents: 12000 }, + billingInterval: 'monthly', + currentPeriodEnd: '2026-09-13T10:45:22.000Z', + }; + const PREVIEW = '/organizations/org_abc12345/billing/usage-tier/preview'; + const APPLY = '/organizations/org_abc12345/billing/usage-tier'; + + /** Routes by path so a test only states the responses it cares about; + * `overrides` supplies the preview/apply results under test. */ + function mockApi( + overrides: Record, + sub: Record = SUB, + ): void { + vi.mocked(apiClient).mockImplementation(async (path: string) => { + if (path in overrides) return overrides[path]; + if (path === '/workspaces') return workspaces; + if (path === '/organizations/org_abc12345/billing/subscription') return sub; + if (path === '/plans') return CATALOG; + throw new Error(`unexpected path: ${path}`); + }); + } + + let origTTY: typeof process.stdout.isTTY; + let origStdinTTY: typeof process.stdin.isTTY; + let log: ReturnType; beforeEach(() => { vi.mocked(apiClient).mockReset(); vi.mocked(open).mockClear(); + selectCalls = []; + selectAnswers = []; + confirmAnswers = []; process.env.HOOKMYAPP_APP_URL = 'https://app.test'; + origTTY = process.stdout.isTTY; + origStdinTTY = process.stdin.isTTY; + process.stdout.isTTY = true; + process.stdin.isTTY = true; + log = vi.spyOn(console, 'log').mockImplementation(() => {}); }); afterEach(() => { delete process.env.HOOKMYAPP_APP_URL; + process.stdout.isTTY = origTTY; + process.stdin.isTTY = origStdinTTY; + log.mockRestore(); }); - test('When paid tier with active status, then billingUpgrade reads the org subscription route, opens the app Billing page, and never calls a /stripe/* route', async () => { - vi.mocked(apiClient).mockImplementation(async (path: string) => { - if (path === '/organizations/org_abc12345/billing/subscription') { - return { - status: 'active', - plan: { slug: 'launch', name: 'Launch+', priceInCents: 1900, annualPriceInCents: 19000 }, - }; - } - if (path === '/workspaces') return workspaces; - throw new Error(`unexpected path: ${path}`); + function printed(): string { + return log.mock.calls.flat().join('\n'); + } + + test('When upgrading, then the prorated charge is stated and confirming applies the change without a browser', async () => { + mockApi({ + [PREVIEW]: { scheduled: false, amountDueCents: 1200, currency: 'usd' }, + [APPLY]: { scheduled: false }, + }); + queueSelectAnswers('growth'); + queueConfirmAnswers(true); + + await expect(billingUpgrade()).resolves.toBeUndefined(); + + expect(printed()).toContain( + "You'll switch to Scale right away. We'll charge $12.00 today for the rest of this billing period (through Sep 13, 2026). On Sep 13, 2026 your next bill is the full Scale price.", + ); + expect(printed()).toContain('✓ Switched to Scale (100,000 messages/mo)'); + expect(vi.mocked(open)).not.toHaveBeenCalled(); + }); + + test('When applying, then preview and apply POST the same plan and the inherited interval', async () => { + mockApi({ + [PREVIEW]: { scheduled: false, amountDueCents: 1200 }, + [APPLY]: { scheduled: false }, + }); + queueSelectAnswers('growth'); + queueConfirmAnswers(true); + + await billingUpgrade(); + + const sent = vi + .mocked(apiClient) + .mock.calls.filter((c) => c[0] === PREVIEW || c[0] === APPLY) + .map((c) => ({ path: c[0], ...(c[1] as { method: string; body: string }) })); + expect(sent.map((s) => s.path)).toEqual([PREVIEW, APPLY]); + for (const call of sent) { + expect(call.method).toBe('POST'); + expect(JSON.parse(call.body)).toEqual({ planSlug: 'growth', billingInterval: 'monthly' }); + } + }); + + test('When the plan list is offered, then the current plan is excluded and the interval is not asked', async () => { + mockApi({ + [PREVIEW]: { scheduled: false, amountDueCents: 1200 }, + [APPLY]: { scheduled: false }, + }); + queueSelectAnswers('growth'); + queueConfirmAnswers(true); + + await billingUpgrade(); + + const choices = capturedSelectCalls()[0].choices as Array<{ value: string }>; + expect(choices.map((c) => c.value)).toEqual(['growth', 'pro']); + expect(capturedSelectCalls()).toHaveLength(1); + }); + + test('When the change is a downgrade, then it is described as scheduled and charges nothing now', async () => { + mockApi({ + [PREVIEW]: { scheduled: true }, + [APPLY]: { scheduled: true, effectiveAt: '2026-09-13T10:45:22.000Z' }, }); + queueSelectAnswers('pro'); + queueConfirmAnswers(true); + + await billingUpgrade(); + + expect(printed()).toContain( + "You'll keep Build and its usage until Sep 13, 2026. Business starts then, and nothing is charged now.", + ); + expect(printed()).toContain('✓ Business starts on Sep 13, 2026.'); + }); + + test('When the confirmation is declined, then nothing is applied', async () => { + mockApi({ [PREVIEW]: { scheduled: false, amountDueCents: 1200 } }); + queueSelectAnswers('growth'); + queueConfirmAnswers(false); await expect(billingUpgrade()).resolves.toBeUndefined(); + const paths = vi.mocked(apiClient).mock.calls.map((c) => String(c[0])); + expect(paths).not.toContain(APPLY); + expect(printed()).toContain('No changes made.'); + }); + + test('When a cancel is already pending, then the Billing page opens instead of promising an immediate switch', async () => { + mockApi({}, { ...SUB, cancelAtPeriodEnd: true }); + + await billingUpgrade(); + expect(vi.mocked(open)).toHaveBeenCalledWith('https://app.test/org/org_abc12345/billing'); const paths = vi.mocked(apiClient).mock.calls.map((c) => String(c[0])); - expect(paths.some((p) => p.startsWith('/stripe/'))).toBe(false); + expect(paths).not.toContain(PREVIEW); + }); + + test('When the subscription bills annually, then the change is sent as annual', async () => { + mockApi( + { [PREVIEW]: { scheduled: false, amountDueCents: 0 }, [APPLY]: { scheduled: false } }, + { ...SUB, billingInterval: 'annual' }, + ); + queueSelectAnswers('growth'); + queueConfirmAnswers(true); + + await billingUpgrade(); + + const applyCall = vi.mocked(apiClient).mock.calls.find((c) => c[0] === APPLY)!; + expect(JSON.parse(String((applyCall[1] as { body: string }).body)).billingInterval).toBe('annual'); + }); + + test('When the subscription carries no billing interval, then the Billing page opens rather than assuming monthly', async () => { + const { billingInterval: _dropped, ...noInterval } = SUB; + mockApi({}, noInterval); + + await billingUpgrade(); + + expect(vi.mocked(open)).toHaveBeenCalledWith('https://app.test/org/org_abc12345/billing'); + const bodies = vi.mocked(apiClient).mock.calls.map((c) => JSON.stringify(c[1] ?? '')); + expect(bodies.some((b) => b.includes('monthly'))).toBe(false); + }); + + test('When the org is on a Custom plan, then the Billing page opens (not in the tier catalog)', async () => { + mockApi({}, { ...SUB, plan: { slug: 'custom', name: 'Custom' } }); + + await billingUpgrade(); + + expect(vi.mocked(open)).toHaveBeenCalledWith('https://app.test/org/org_abc12345/billing'); + }); + + test('When no TTY, then it fails with UPGRADE_REQUIRES_TTY before touching the API', async () => { + process.stdout.isTTY = false; + mockApi({}); + + await expect(billingUpgrade()).rejects.toMatchObject({ code: 'UPGRADE_REQUIRES_TTY' }); + + // Guarding before the workspace/subscription lookups means a non-TTY run + // reports why it can't proceed instead of whatever those calls hit first. + expect(vi.mocked(apiClient)).not.toHaveBeenCalled(); + expect(vi.mocked(open)).not.toHaveBeenCalled(); + }); + + test('When stdin is redirected, then it refuses rather than prompting into a pipe', async () => { + // stdout can be a terminal while stdin is a pipe (`billing upgrade < /dev/null`). + // The prompt would render and then have nothing to read the answer from. + process.stdin.isTTY = false; + mockApi({}); + + await expect(billingUpgrade()).rejects.toMatchObject({ code: 'UPGRADE_REQUIRES_TTY' }); + + expect(capturedSelectCalls()).toHaveLength(0); }); }); describe('billingUpgrade — free tier plan prompt (GET /plans)', () => { - const CATALOG = [ - { slug: 'free', name: 'Launch', messages: 2000, priceInCents: 0, annualPriceInCents: 0 }, - { slug: 'starter', name: 'Build', messages: 30000, priceInCents: 1200, annualPriceInCents: 12000 }, - { slug: 'growth', name: 'Scale', messages: 100000, priceInCents: 2400, annualPriceInCents: 24000, popular: true }, - // Non-whole monthly price (AIT-391): $39.99, not the rounded-to-$40 that - // Math.round(priceInCents / 100) used to render. - { slug: 'pro', name: 'Business', messages: 250000, priceInCents: 3999, annualPriceInCents: 39000 }, - ]; - let origTTY: typeof process.stdout.isTTY; + let origStdinTTY: typeof process.stdin.isTTY; beforeEach(() => { vi.mocked(apiClient).mockReset(); vi.mocked(open).mockClear(); @@ -182,11 +361,14 @@ describe('billingUpgrade — free tier plan prompt (GET /plans)', () => { selectAnswers = []; process.env.HOOKMYAPP_APP_URL = 'https://app.test'; origTTY = process.stdout.isTTY; + origStdinTTY = process.stdin.isTTY; process.stdout.isTTY = true; + process.stdin.isTTY = true; }); afterEach(() => { delete process.env.HOOKMYAPP_APP_URL; process.stdout.isTTY = origTTY; + process.stdin.isTTY = origStdinTTY; // Fake timers are per-test opt-in (three tests below poll under // vi.useFakeTimers()) — restore real timers here rather than at the end // of each test body, so a thrown assertion between `await run` and @@ -293,4 +475,9 @@ describe('billingUpgrade — free tier plan prompt (GET /plans)', () => { await advanceUntilSettled(run); await assertion; }); -}); + // advanceUntilSettled drives up to 500 real event-loop turns per poll test. + // On a loaded CI runner that can exceed the 5s default and time out even + // though nothing is wrong — it already timed out once on this branch. The + // block-level budget keeps these tests honest without making each one + // declare its own. +}, 30_000); diff --git a/src/commands/billing.ts b/src/commands/billing.ts index a76a6b3..cb8b20a 100644 --- a/src/commands/billing.ts +++ b/src/commands/billing.ts @@ -30,6 +30,53 @@ function orgBillingUrl(orgPublicId: string): string { return `${getEffectiveAppUrl()}/org/${orgPublicId}/billing`; } +/** `Sep 13, 2026`, matching the dashboard's confirm dialog. Fixed to en-US so + * the sentence reads the same everywhere the CLI runs. Null for a missing or + * unparseable date — callers drop the clause rather than print "Invalid Date". */ +function formatDate(iso?: string): string | null { + if (!iso) return null; + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return null; + return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }); +} + +type TierPreview = { scheduled: boolean; amountDueCents?: number; currency?: string }; + +/** The terminal wording of the dashboard's tier-switch dialog (AIT-398). Every + * claim comes from the server's preview: an upgrade states the prorated charge + * it returned, a downgrade (`scheduled`) states that nothing is charged now. + * Clauses that need the period end are dropped when the subscription doesn't + * carry one — never guessed. */ +function describeTierChange(args: { + targetName: string; + currentName: string; + preview: TierPreview; + currentPeriodEnd?: string; +}): string { + const { targetName, currentName, preview } = args; + const on = formatDate(args.currentPeriodEnd); + + if (preview.scheduled) { + return ( + `You'll keep ${currentName} and its usage until ${on ?? 'the end of your billing period'}. ` + + `${targetName} starts then, and nothing is charged now.` + ); + } + + const nextBill = on + ? `On ${on} your next bill is the full ${targetName} price.` + : `Your next bill is the full ${targetName} price.`; + const due = preview.amountDueCents ?? 0; + if (due > 0) { + const through = on ? ` (through ${on})` : ''; + return ( + `You'll switch to ${targetName} right away. We'll charge $${(due / 100).toFixed(2)} today ` + + `for the rest of this billing period${through}. ${nextBill}` + ); + } + return `You'll switch to ${targetName} right away at no extra charge today. ${nextBill}`; +} + const UPGRADE_POLL_INTERVAL_MS = 5_000; const UPGRADE_POLL_HINT_EVERY_MS = 60_000; @@ -139,20 +186,128 @@ export async function billingStatus(opts: { json?: boolean; human?: boolean } = } } +type CatalogPlan = { + slug: string; + name: string; + messages: number; + priceInCents: number; + annualPriceInCents: number; + popular?: true; +}; + +/** Live catalog — the CLI keeps no copy of plan names, limits, or prices; + * hardcoded copies drift and have shipped stale numbers before. A fetch + * failure fails the command; there is deliberately no fallback list. + * `exceptSlug` drops the plan the org is already on. */ +async function fetchPaidPlans(exceptSlug?: string): Promise { + const catalog = (await apiClient('/plans')) as CatalogPlan[]; + const paidPlans = catalog.filter((p) => p.priceInCents > 0 && p.slug !== exceptSlug); + if (paidPlans.length === 0) { + throw new ValidationError('No paid plans available. Try again later.', 'PLANS_EMPTY'); + } + return paidPlans; +} + +function planChoice(p: CatalogPlan): { name: string; value: string; description?: string } { + return { + name: `${p.name}: ${p.messages.toLocaleString('en-US')} messages — ${formatPrice(p.priceInCents)}/mo (or ${formatPrice(p.annualPriceInCents)}/yr)`, + value: p.slug, + ...(p.popular ? { description: 'Most popular' } : {}), + }; +} + +/** Plan change for an org that already pays (AIT-398). Same two endpoints the + * dashboard's confirm dialog uses, so the terminal states the billing effect + * and applies it — no browser, and no login wall for a CLI-only user. */ +async function changePlanInTerminal( + orgPublicId: string, + sub: { + plan: { slug: string; name: string }; + billingInterval: 'monthly' | 'annual'; + currentPeriodEnd?: string; + }, +): Promise { + const plans = await fetchPaidPlans(sub.plan.slug); + + const { select, confirm } = await import('@inquirer/prompts'); + const planSlug = await select({ + message: 'Choose a plan', + choices: plans.map(planChoice), + }); + const target = plans.find((p) => p.slug === planSlug)!; + // Interval is whatever the subscription already bills on — switching monthly + // ↔ annual is a separate decision and stays on the Billing page. Never + // defaulted: guessing `monthly` for an annual customer would move their + // billing cadence behind a prompt that only mentioned the plan. + const body = JSON.stringify({ planSlug, billingInterval: sub.billingInterval }); + + const preview = (await apiClient(`/organizations/${orgPublicId}/billing/usage-tier/preview`, { + method: 'POST', + body, + })) as TierPreview; + console.log( + describeTierChange({ + targetName: target.name, + currentName: sub.plan.name, + preview, + currentPeriodEnd: sub.currentPeriodEnd, + }), + ); + + const ok = await confirm({ message: `Switch to ${target.name}?`, default: false }); + if (!ok) { + console.log('No changes made.'); + return; + } + + const result = (await apiClient(`/organizations/${orgPublicId}/billing/usage-tier`, { + method: 'POST', + body, + })) as { scheduled: boolean; effectiveAt?: string }; + + if (result.scheduled) { + const on = formatDate(result.effectiveAt ?? sub.currentPeriodEnd); + console.log( + on + ? `✓ ${target.name} starts on ${on}.` + : `✓ ${target.name} starts at the end of your billing period.`, + ); + return; + } + console.log( + `✓ Switched to ${target.name} (${target.messages.toLocaleString('en-US')} messages/mo).`, + ); +} + export async function billingUpgrade(opts: { json?: boolean } = {}): Promise { - // `billing upgrade` is interactive end-to-end: the free path prompts for a - // plan + interval, and both paths open a browser. There is no machine- - // readable form, so reject --json up front with a clear pointer instead of - // rendering an inquirer prompt that aborts into a generic error in non-TTY - // / --json contexts. + // `billing upgrade` is interactive end-to-end: both paths prompt for a plan + // and confirm before anything is charged. There is no machine-readable form, + // so reject --json up front with a clear pointer instead of rendering an + // inquirer prompt that aborts into a generic error in non-TTY / --json + // contexts. if (opts.json) { throw new ValidationError( - `billing upgrade is interactive (plan selection + browser checkout) and has no --json form. ` + - `Run it without --json from a terminal, or use \`${cliCommandPrefix()} billing manage\` for an existing subscription.`, + `billing upgrade is interactive (plan selection + confirmation) and has no --json form. ` + + `Run it without --json from a terminal, or use \`${cliCommandPrefix()} billing manage\` to open the Billing page.`, 'UPGRADE_NO_JSON', ); } + // Both paths prompt, so the TTY guard covers both (mirrors the + // `channels connect` / `login` non-TTY guard); without it the @inquirer + // prompt aborts into a confusing generic error. stdin matters as much as + // stdout — a redirected stdin renders the prompt and then can't read the + // answer, which is the same dead end with a worse error. Checked before any + // network call so a non-TTY run fails on the reason it can't proceed rather + // than on whatever the workspace or subscription lookup happens to hit. + if (process.stdin.isTTY !== true || process.stdout.isTTY !== true) { + throw new ValidationError( + `billing upgrade requires an interactive terminal to choose a plan. Re-run from a TTY, ` + + `or use \`${cliCommandPrefix()} billing manage\` to manage an existing subscription.`, + 'UPGRADE_REQUIRES_TTY', + ); + } + const workspaceId = await getDefaultWorkspaceId(); const orgPublicId = await resolveOrgPublicIdForWorkspace(workspaceId); const sub = await apiClient(`/organizations/${orgPublicId}/billing/subscription`); @@ -162,48 +317,32 @@ export async function billingUpgrade(opts: { json?: boolean } = {}): Promise p.priceInCents > 0); - if (paidPlans.length === 0) { - throw new ValidationError('No paid plans available. Try again later.', 'PLANS_EMPTY'); - } - + const paidPlans = await fetchPaidPlans(); const { select } = await import('@inquirer/prompts'); const planSlug = await select({ message: 'Choose a plan', - choices: paidPlans.map((p) => ({ - name: `${p.name}: ${p.messages.toLocaleString('en-US')} messages — ${formatPrice(p.priceInCents)}/mo (or ${formatPrice(p.annualPriceInCents)}/yr)`, - value: p.slug, - ...(p.popular ? { description: 'Most popular' } : {}), - })), + choices: paidPlans.map(planChoice), }); const billingInterval = await select({ message: 'Billing interval', @@ -252,7 +391,7 @@ export function registerBillingCommand(_program: Command): void { const billingUpgradeCmd = billing .command('upgrade') - .description('Upgrade plan (interactive for free users, opens your Billing page for subscribers)') + .description('Change your plan (interactive)') .action(async () => { const { program: rootProgram } = await import('../index.js'); await billingUpgrade({ json: !!rootProgram.opts().json });