From 9a070857917490d2025e913f3e0f10bbdf4448c4 Mon Sep 17 00:00:00 2001 From: Or Dvir Date: Tue, 18 Aug 2026 06:29:50 +0300 Subject: [PATCH 01/14] feat: billing eligibility client (AIT-420) --- src/__tests__/client.test.ts | 72 ++++++++++++++++++++++++++++++++++++ src/api/client.ts | 59 +++++++++++++++++++++++++++++ 2 files changed, 131 insertions(+) diff --git a/src/__tests__/client.test.ts b/src/__tests__/client.test.ts index 6807a83..2e07f10 100644 --- a/src/__tests__/client.test.ts +++ b/src/__tests__/client.test.ts @@ -231,3 +231,75 @@ describe('apiClient', () => { } }); }); + +describe('getBillingEligibility', () => { + let getBillingEligibility: typeof import('../api/client.js').getBillingEligibility; + let AuthError: typeof import('../output/error.js').AuthError; + + beforeEach(async () => { + vi.resetModules(); + mockFetch.mockReset(); + mockedReadCredentials.mockReset(); + mockedSaveCredentials.mockReset(); + + const futureExp = Math.floor(Date.now() / 1000) + 3600; + const payload = Buffer.from(JSON.stringify({ exp: futureExp })).toString('base64'); + mockedReadCredentials.mockResolvedValue({ + accessToken: `header.${payload}.sig`, + refreshToken: 'rt', + expiresAt: futureExp, + }); + + const mod = await import('../api/client.js'); + const errMod = await import('../output/error.js'); + getBillingEligibility = mod.getBillingEligibility; + AuthError = errMod.AuthError; + }); + + afterEach(() => { + delete process.env.HOOKMYAPP_API_URL; + }); + + it('GETs /organizations/:orgId/billing/eligibility with the auth header and parses the response', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ eligiblePlan: 'build', trialActions: 37, trialStatus: 'active' }), + }); + + const result = await getBillingEligibility('org_abc12345'); + + expect(mockFetch).toHaveBeenCalledWith( + 'https://api.hookmyapp.com/organizations/org_abc12345/billing/eligibility', + expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: expect.stringContaining('Bearer '), + }), + }), + ); + expect(result).toEqual({ eligiblePlan: 'build', trialActions: 37, trialStatus: 'active' }); + }); + + it('returns null (legacy world) when the backend 400s with PLAN_NOT_AVAILABLE', async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 400, + json: async () => ({ message: 'Money model v2 not enabled', code: 'PLAN_NOT_AVAILABLE' }), + statusText: 'Bad Request', + }); + + const result = await getBillingEligibility('org_abc12345'); + + expect(result).toBeNull(); + }); + + it('rethrows other errors (e.g. 401) unchanged', async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 401, + json: async () => ({ message: 'Unauthorized' }), + statusText: 'Unauthorized', + }); + + await expect(getBillingEligibility('org_abc12345')).rejects.toBeInstanceOf(AuthError); + }); +}); diff --git a/src/api/client.ts b/src/api/client.ts index 040cb1d..1ddccaf 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -401,6 +401,65 @@ export async function apiClient( } } +// --- money model v2 billing contract (Plan 05 Task 1) --- +// +// Mirrors backend/src/organizations/billing/eligibility.controller.ts. +// `eligiblePlan` is which plan a trial/expired org is allowed to check out +// into — never a picker, the eligibility gate decides. `trialStatus` mirrors +// the subscription's own `trial.status` but is returned unauthenticated-org- +// scoped so the CLI can call it before deciding whether to show a picker at +// all (Task 3). +export interface BillingEligibility { + eligiblePlan: 'build' | 'scale'; + trialActions: number; + trialStatus: 'not_started' | 'active' | 'expired'; +} + +// Plan 02 Task 6's additive subscription contract. Legacy orgs never carry +// these fields (usageUnit undefined) — callers branch on `usageUnit`/`trial` +// presence, never re-derive "is this a money-model-v2 org" some other way. +export interface BillingTrial { + status: 'not_started' | 'active' | 'expired'; + endsAt: string | null; + daysLeft: number | null; +} + +export interface BillingSubscription { + status: string; + plan: { slug: string; name: string; messages: number; priceInCents?: number; annualPriceInCents?: number }; + billingInterval?: 'monthly' | 'annual'; + currentPeriodEnd?: string; + cancelAtPeriodEnd?: boolean; + pendingPlanChange?: unknown; + // Additive — absent entirely for legacy (messages-generation) orgs. + usageUnit?: 'messages' | 'actions'; + actionsUsed?: number; + actionsQuota?: number | null; // null = unlimited + unlimited?: boolean; + trial?: BillingTrial | null; +} + +/** + * Fetch checkout eligibility for the org. Returns `null` when the backend has + * the money-model-v2 flag off (400 `PLAN_NOT_AVAILABLE`) — callers treat null + * as "legacy world" and keep pre-v2 output/behavior unchanged. Any other + * error rethrows through the normal mapApiError contract. + */ +export async function getBillingEligibility( + orgPublicId: string, +): Promise { + try { + return (await apiClient( + `/organizations/${orgPublicId}/billing/eligibility`, + )) as BillingEligibility; + } catch (err) { + if (err instanceof ApiError && err.code === 'PLAN_NOT_AVAILABLE') { + return null; + } + throw err; + } +} + // --- sandbox bind-code contract --- // // Mirrors backend/src/sandbox/bind-code.controller.ts (Plan 03 Wave 2 locked From f4679e212b238d6a7a4c75635d126c49157e4652 Mon Sep 17 00:00:00 2001 From: Or Dvir Date: Tue, 18 Aug 2026 06:32:59 +0300 Subject: [PATCH 02/14] feat: billing shows trial + action plans (AIT-420) --- src/__tests__/billing.test.ts | 183 +++++++++++++++++++++++++++++++++- src/commands/billing.ts | 92 ++++++++++++++++- 2 files changed, 271 insertions(+), 4 deletions(-) diff --git a/src/__tests__/billing.test.ts b/src/__tests__/billing.test.ts index 52ea4ce..cbaf490 100644 --- a/src/__tests__/billing.test.ts +++ b/src/__tests__/billing.test.ts @@ -7,6 +7,7 @@ vi.mock('../api/client.js', () => ({ // pollForUpgrade's transient-error check calls this on every poll failure; // default to "not a network blip" so it never masks a real error. isNetworkFailure: vi.fn(() => false), + getBillingEligibility: vi.fn(), })); // Mock open @@ -32,11 +33,12 @@ const mockExit = vi.spyOn(process, 'exit').mockImplementation((() => { const mockConsoleError = vi.spyOn(console, 'error').mockImplementation(() => {}); const mockConsoleLog = vi.spyOn(console, 'log').mockImplementation(() => {}); -import { apiClient } from '../api/client.js'; +import { apiClient, getBillingEligibility } from '../api/client.js'; import openDefault from 'open'; const mockedApiClient = vi.mocked(apiClient); const mockedOpen = vi.mocked(openDefault); +const mockedGetBillingEligibility = vi.mocked(getBillingEligibility); const WORKSPACE_ID = 'ws_TEST0070'; const ORG_PUBLIC_ID = 'org_abc12345'; @@ -104,6 +106,7 @@ describe('billing commands', () => { vi.resetModules(); mockedApiClient.mockReset(); mockedOpen.mockReset(); + mockedGetBillingEligibility.mockReset(); mockExit.mockClear(); mockConsoleError.mockClear(); mockConsoleLog.mockClear(); @@ -215,6 +218,184 @@ describe('billing commands', () => { }); }); + describe('billingStatus — money model v2 (trial + action plans)', () => { + beforeEach(() => { + vi.stubEnv('HOOKMYAPP_APP_URL', 'https://app.test'); + }); + afterEach(() => { + vi.unstubAllEnvs(); + }); + + const BILLING_URL = `https://app.test/org/${ORG_PUBLIC_ID}/billing`; + + it('trial not started: exact copy, no channel yet', async () => { + mockSubAndUsage( + { + status: 'trialing', + plan: { slug: 'build', name: 'Build', messages: 0 }, + usageUnit: 'actions', + actionsUsed: 0, + actionsQuota: null, + unlimited: true, + trial: { status: 'not_started', endsAt: null, daysLeft: null }, + }, + { totalMessages: 0, limit: 0, percentage: 0 }, + ); + + await billingStatus({ human: true }); + + const logged = mockConsoleLog.mock.calls.map((c) => String(c[0])).join('\n'); + expect(logged).toBe('Plan: Free trial — starts when you connect your first channel'); + expect(mockedGetBillingEligibility).not.toHaveBeenCalled(); + }); + + it('trial active: days left, actions so far, add-card hint', async () => { + mockSubAndUsage( + { + status: 'trialing', + plan: { slug: 'build', name: 'Build', messages: 0 }, + usageUnit: 'actions', + actionsUsed: 37, + actionsQuota: null, + unlimited: true, + trial: { status: 'active', endsAt: '2026-08-22T00:00:00.000Z', daysLeft: 4 }, + }, + { totalMessages: 0, limit: 0, percentage: 0 }, + ); + + await billingStatus({ human: true }); + + const logged = mockConsoleLog.mock.calls.map((c) => String(c[0])).join('\n'); + expect(logged).toContain('Plan: Free trial — 4 days left · 37 actions so far (unlimited during trial)'); + expect(logged).toContain(`Add a card so nothing stops when the trial ends: ${BILLING_URL}`); + }); + + it('trial expired: paused copy + resume line from eligibility plan/price', async () => { + mockSubAndUsage( + { + status: 'trialing', + plan: { slug: 'build', name: 'Build', messages: 0 }, + usageUnit: 'actions', + actionsUsed: 12, + actionsQuota: null, + unlimited: false, + trial: { status: 'expired', endsAt: '2026-08-10T00:00:00.000Z', daysLeft: 0 }, + }, + { totalMessages: 0, limit: 0, percentage: 0 }, + ); + mockedGetBillingEligibility.mockResolvedValueOnce({ + eligiblePlan: 'build', + trialActions: 12, + trialStatus: 'expired', + }); + + await billingStatus({ human: true }); + + const logged = mockConsoleLog.mock.calls.map((c) => String(c[0])).join('\n'); + expect(logged).toContain('Your trial ended — channels are paused.'); + expect(logged).toContain(`Resume on Build ($1/month): ${BILLING_URL}`); + expect(mockedGetBillingEligibility).toHaveBeenCalledWith(ORG_PUBLIC_ID); + }); + + it('build org under 75% usage: plan line only, no upsell', async () => { + mockSubAndUsage( + { + status: 'active', + plan: { slug: 'build', name: 'Build', messages: 0 }, + usageUnit: 'actions', + actionsUsed: 137, + actionsQuota: 200, + unlimited: false, + trial: null, + }, + { totalMessages: 0, limit: 0, percentage: 0 }, + ); + + await billingStatus({ human: true }); + + const logged = mockConsoleLog.mock.calls.map((c) => String(c[0])).join('\n'); + expect(logged).toBe('Plan: Build — 137/200 actions this period'); + }); + + it('build org at/above 75% usage: shows Scale upsell hint', async () => { + mockSubAndUsage( + { + status: 'active', + plan: { slug: 'build', name: 'Build', messages: 0 }, + usageUnit: 'actions', + actionsUsed: 180, + actionsQuota: 200, + unlimited: false, + trial: null, + }, + { totalMessages: 0, limit: 0, percentage: 0 }, + ); + + await billingStatus({ human: true }); + + const logged = mockConsoleLog.mock.calls.map((c) => String(c[0])).join('\n'); + expect(logged).toContain('Plan: Build — 180/200 actions this period'); + expect(logged).toContain('Running hot? Scale gives you 15,000 actions for $24/month.'); + }); + + it('scale org: plan line with comma-formatted quota, no upsell', async () => { + mockSubAndUsage( + { + status: 'active', + plan: { slug: 'scale', name: 'Scale', messages: 0 }, + usageUnit: 'actions', + actionsUsed: 12406, + actionsQuota: 15000, + unlimited: false, + trial: null, + }, + { totalMessages: 0, limit: 0, percentage: 0 }, + ); + + await billingStatus({ human: true }); + + const logged = mockConsoleLog.mock.calls.map((c) => String(c[0])).join('\n'); + expect(logged).toBe('Plan: Scale — 12,406/15,000 actions this period'); + }); + + it('legacy org (no usageUnit) is untouched: no eligibility call, existing output shape', async () => { + mockSubAndUsage(activeSub, { totalMessages: 600, limit: 1200, percentage: 50 }); + + await billingStatus({ human: true }); + + expect(mockedGetBillingEligibility).not.toHaveBeenCalled(); + const logged = mockConsoleLog.mock.calls.map((c) => String(c[0])).join('\n'); + expect(logged).toContain('plan: Scale'); + expect(logged).not.toContain('actions this period'); + }); + + it('--json is additive: adds plan/actionsUsed/actionsQuota/trial for a money-model-v2 org', async () => { + mockSubAndUsage( + { + status: 'active', + plan: { slug: 'build', name: 'Build', messages: 0 }, + usageUnit: 'actions', + actionsUsed: 137, + actionsQuota: 200, + unlimited: false, + trial: null, + }, + { totalMessages: 0, limit: 0, percentage: 0 }, + ); + + await billingStatus({ human: false }); + + const calls = mockConsoleLog.mock.calls.map((c) => c[0]); + const jsonCall = calls.find((c) => typeof c === 'string' && c.includes('"subscription"')); + expect(jsonCall).toBeDefined(); + const parsed = JSON.parse(jsonCall as string); + expect(parsed.plan).toBe('Build'); + expect(parsed.actionsUsed).toBe(137); + expect(parsed.actionsQuota).toBe(200); + expect(parsed.trial).toBeNull(); + }); + }); + describe('billingManage', () => { // /stripe/portal is retired (410 BILLING_PORTAL_RETIRED). `billing manage` // now opens the app's org Billing page, resolving the org publicId from diff --git a/src/commands/billing.ts b/src/commands/billing.ts index cb8b20a..8343cc0 100644 --- a/src/commands/billing.ts +++ b/src/commands/billing.ts @@ -1,6 +1,6 @@ import { Command } from 'commander'; import open from 'open'; -import { apiClient, isNetworkFailure } from '../api/client.js'; +import { apiClient, isNetworkFailure, getBillingEligibility, type BillingSubscription } from '../api/client.js'; import { output } from '../output/format.js'; import { c } from '../output/color.js'; import { ApiError, NetworkError, ValidationError } from '../output/error.js'; @@ -140,23 +140,109 @@ export async function billingManage(opts: { json?: boolean } = {}): Promise = { + build: { name: 'Build', priceLabel: '$1/month' }, + scale: { name: 'Scale', priceLabel: '$24/month' }, +}; + +/** Money-model-v2 org (`sub.usageUnit === 'actions'`) status rendering — + * trial states, then paid Build/Scale usage. Never called for legacy orgs. */ +async function printMoneyModelStatus(orgPublicId: string, sub: BillingSubscription): Promise { + const billingUrl = orgBillingUrl(orgPublicId); + + if (sub.trial) { + if (sub.trial.status === 'not_started') { + console.log('Plan: Free trial — starts when you connect your first channel'); + return; + } + if (sub.trial.status === 'active') { + const daysLeft = sub.trial.daysLeft ?? 0; + const actionsUsed = sub.actionsUsed ?? 0; + console.log( + `Plan: Free trial — ${daysLeft} day${daysLeft === 1 ? '' : 's'} left · ` + + `${actionsUsed.toLocaleString('en-US')} actions so far (unlimited during trial)`, + ); + console.log(`Add a card so nothing stops when the trial ends: ${billingUrl}`); + return; + } + if (sub.trial.status === 'expired') { + // Plan name/price for the resume line comes from eligibility, not the + // (paused) subscription's own plan — that's the source of truth for + // which plan the org is allowed to resume onto. + const eligibility = await getBillingEligibility(orgPublicId); + const display = eligibility ? ELIGIBLE_PLAN_DISPLAY[eligibility.eligiblePlan] : undefined; + console.log('Your trial ended — channels are paused.'); + console.log( + display + ? `Resume on ${display.name} (${display.priceLabel}): ${billingUrl}` + : `Resume your plan: ${billingUrl}`, + ); + return; + } + } + + // Paid Build/Scale org (no active trial). NULL-GUARD: actionsQuota is null + // for unlimited — never call .toLocaleString on it directly. + const used = (sub.actionsUsed ?? 0).toLocaleString('en-US'); + const quota = sub.actionsQuota; + const quotaLabel = quota === null || quota === undefined ? 'unlimited' : quota.toLocaleString('en-US'); + console.log(`Plan: ${sub.plan.name} — ${used}/${quotaLabel} actions this period`); + + if (typeof quota === 'number' && quota > 0 && sub.plan.slug !== 'scale') { + const usedRatio = (sub.actionsUsed ?? 0) / quota; + if (usedRatio >= 0.75) { + console.log(SCALE_UPSELL_HINT); + } + } +} + export async function billingStatus(opts: { json?: boolean; human?: boolean } = {}): Promise { const workspaceId = await getDefaultWorkspaceId(); const orgPublicId = await resolveOrgPublicIdForWorkspace(workspaceId); - const [sub, usage] = await Promise.all([ + const [sub, usage] = (await Promise.all([ apiClient(`/organizations/${orgPublicId}/billing/subscription`), apiClient('/webhook/usage', { workspaceId }), - ]); + ])) as [BillingSubscription, { totalMessages: number; limit: number; percentage: number }]; // Accept either `json: true` or `human: false` (back-compat with callers // and tests that predate the phase-108 opts shape). const isJson = opts.json === true || opts.human === false; + // Branch on usageUnit — never re-derive generation from plan slug. Legacy + // orgs (usageUnit undefined) get byte-identical output to before this + // feature existed; money-model-v2 orgs (usageUnit 'actions'/'messages') + // get the new trial/action-plan rendering. + const isMoneyModelV2 = sub.usageUnit !== undefined; + if (isJson) { + if (isMoneyModelV2) { + output( + { + subscription: sub, + usage, + plan: sub.plan.name, + actionsUsed: sub.actionsUsed ?? null, + actionsQuota: sub.actionsQuota ?? null, + trial: sub.trial ? { daysLeft: sub.trial.daysLeft, endsAt: sub.trial.endsAt } : null, + }, + { json: true }, + ); + return; + } output({ subscription: sub, usage }, { json: true }); return; } + if (isMoneyModelV2) { + await printMoneyModelStatus(orgPublicId, sub); + return; + } + const plan = sub.plan.name; const status = sub.status; const interval = sub.billingInterval ?? 'n/a'; From 84d4b153f9f40839b371992223942a65050e1760 Mon Sep 17 00:00:00 2001 From: Or Dvir Date: Tue, 18 Aug 2026 06:35:52 +0300 Subject: [PATCH 03/14] feat: eligibility-locked upgrade (AIT-420) --- src/__tests__/billing.test.ts | 74 +++++++++++++++++++++++++++++++++ src/commands/billing.ts | 77 ++++++++++++++++++++++++++++++----- 2 files changed, 141 insertions(+), 10 deletions(-) diff --git a/src/__tests__/billing.test.ts b/src/__tests__/billing.test.ts index cbaf490..4c7b6c4 100644 --- a/src/__tests__/billing.test.ts +++ b/src/__tests__/billing.test.ts @@ -594,6 +594,80 @@ describe('billing commands', () => { })); expect(mockedOpen).toHaveBeenCalledWith('https://checkout.stripe.com/x'); }); + + it('expired trial org: suppresses the plan picker and checks out only the eligible plan', async () => { + const origTTY = process.stdout.isTTY; + const origStdinTTY = process.stdin.isTTY; + process.stdout.isTTY = true; + process.stdin.isTTY = true; + const inq = await import('@inquirer/prompts'); + // Only ONE select call expected: billing interval. No "Choose a plan" + // prompt — the eligible plan is not user-selectable. + vi.mocked(inq.select).mockResolvedValueOnce('monthly' as never); + mockedGetBillingEligibility.mockResolvedValueOnce({ + eligiblePlan: 'build', + trialActions: 12, + trialStatus: 'expired', + }); + + let checkoutMinted = false; + mockedApiClient.mockImplementation(async (path: string) => { + if (path === SUBSCRIPTION_PATH) { + return checkoutMinted + ? { + status: 'active', + usageUnit: 'actions', + actionsUsed: 0, + actionsQuota: 200, + plan: { slug: 'build', name: 'Build', messages: 0 }, + } + : { + status: 'trialing', + usageUnit: 'actions', + actionsUsed: 12, + actionsQuota: null, + plan: { slug: 'build', name: 'Build', messages: 0 }, + trial: { status: 'expired', endsAt: '2026-08-10T00:00:00.000Z', daysLeft: 0 }, + }; + } + if (path === '/workspaces') return WORKSPACES; + // The plan catalog must never be fetched on this path — the picker + // is suppressed entirely, so nothing should ask for it. + if (path === '/plans') throw new Error('unexpected /plans fetch on eligibility-locked path'); + if (path === CHECKOUT_PATH) { + checkoutMinted = true; + return { url: 'https://checkout.stripe.com/x' }; + } + throw new Error(`unexpected path: ${path}`); + }); + + await import('../index.js'); + + vi.useFakeTimers(); + try { + const run = billingUpgrade(); + await advanceUntilSettled(run); + await run; + } finally { + process.stdout.isTTY = origTTY; + process.stdin.isTTY = origStdinTTY; + vi.useRealTimers(); + } + + expect(inq.select).toHaveBeenCalledTimes(1); + expect(mockedGetBillingEligibility).toHaveBeenCalledWith(ORG_PUBLIC_ID); + expect(mockedApiClient).toHaveBeenCalledWith(CHECKOUT_PATH, expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ planSlug: 'build', billingInterval: 'monthly' }), + })); + expect(mockedOpen).toHaveBeenCalledWith('https://checkout.stripe.com/x'); + }); + + it('legacy active org still gets the plan picker (unchanged)', async () => { + await runPaidPathDeclining('active'); + + expect(mockedGetBillingEligibility).not.toHaveBeenCalled(); + }); }); }); diff --git a/src/commands/billing.ts b/src/commands/billing.ts index 8343cc0..3c0c86e 100644 --- a/src/commands/billing.ts +++ b/src/commands/billing.ts @@ -87,18 +87,16 @@ const UPGRADE_POLL_HINT_EVERY_MS = 60_000; * (expired auth, revoked permission, client-outdated) rethrow — polling * forever on those would tell the user to "finish checkout" while the CLI * itself is the thing that's broken. */ -async function pollForUpgrade( - orgPublicId: string, -): Promise<{ plan: { slug: string; name: string; messages: number }; status: string }> { +async function pollForUpgrade(orgPublicId: string): Promise { const startedAt = Date.now(); let lastHintAt = startedAt; for (;;) { await new Promise((resolve) => setTimeout(resolve, UPGRADE_POLL_INTERVAL_MS)); - let sub: { plan: { slug: string; name: string; messages: number }; status: string } | null; + let sub: BillingSubscription | null; try { sub = (await apiClient( `/organizations/${orgPublicId}/billing/subscription`, - )) as { plan: { slug: string; name: string; messages: number }; status: string }; + )) as BillingSubscription; } catch (err) { // apiClient wraps raw fetch failures in its own NetworkError before // they ever reach here — isNetworkFailure() inspects the *raw* fetch @@ -365,6 +363,52 @@ async function changePlanInTerminal( ); } +/** Money-model-v2 orgs report usage in actions, not messages — the post- + * checkout confirmation line branches on `usageUnit` instead of assuming + * `plan.messages` is meaningful (it isn't, for an actions-priced plan). */ +function describeUpgradedPlan(sub: BillingSubscription): string { + if (sub.usageUnit === 'actions') { + const quota = sub.actionsQuota; + const quotaLabel = quota === null || quota === undefined ? 'unlimited' : quota.toLocaleString('en-US'); + return `✓ Upgraded to ${sub.plan.name} (${quotaLabel} actions/mo).`; + } + return `✓ Upgraded to ${sub.plan.name} (${sub.plan.messages.toLocaleString('en-US')} messages/mo).`; +} + +/** Trial/expired money-model-v2 orgs (Plan 05 Task 3): eligibility, not the + * user, decides which single plan they may check out into — no "Choose a + * plan" prompt. Interval is still a real choice (Build/Scale both bill + * monthly or annual). */ +async function checkoutEligiblePlan(orgPublicId: string): Promise { + const eligibility = await getBillingEligibility(orgPublicId); + if (!eligibility) { + throw new ValidationError( + 'Could not determine your eligible plan. Try again, or run ' + + `\`${cliCommandPrefix()} billing manage\` to open your Billing page.`, + 'ELIGIBILITY_UNAVAILABLE', + ); + } + + const { select } = await import('@inquirer/prompts'); + const billingInterval = await select({ + message: 'Billing interval', + choices: [ + { name: 'Annual (save ~17%)', value: 'annual' }, + { name: 'Monthly', value: 'monthly' }, + ], + }); + const data = await apiClient(`/organizations/${orgPublicId}/billing/checkout`, { + method: 'POST', + body: JSON.stringify({ planSlug: eligibility.eligiblePlan, billingInterval }), + }); + console.log('Opening Stripe Checkout...'); + await open(data.url); + + console.log('Waiting for payment confirmation... (Ctrl+C to cancel)'); + const upgraded = await pollForUpgrade(orgPublicId); + console.log(describeUpgradedPlan(upgraded)); +} + export async function billingUpgrade(opts: { json?: boolean } = {}): Promise { // `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, @@ -396,7 +440,20 @@ export async function billingUpgrade(opts: { json?: boolean } = {}): Promise Date: Tue, 18 Aug 2026 06:40:24 +0300 Subject: [PATCH 04/14] chore: 0.14.18 changelog + version (AIT-420, publish at cutover) --- CHANGELOG.md | 8 ++++++++ package.json | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 28c2163..cc8e7f1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,14 @@ All notable changes to `@gethookmyapp/cli` are documented here. +## 0.14.18 — 2026-08-18 + +### Changed + +- `hookmyapp billing` shows the new plans. Trial organizations see days left and actions used so far (unlimited during the trial), with a reminder to add a card. Build and Scale organizations see their action count against the plan quota, with a Scale hint when usage runs hot. Organizations on existing plans see the same output as before (AIT-420). +- `hookmyapp billing upgrade` for a trial that ended offers the plan your usage qualifies you for, with no plan picker. Existing plans keep the current picker (AIT-420). +- `--json` output adds `plan`, `actionsUsed`, `actionsQuota` (null means unlimited), and `trial` fields. All existing fields are unchanged (AIT-420). + ## 0.14.16 — 2026-08-14 ### Fixed diff --git a/package.json b/package.json index c891e43..b6b5458 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@gethookmyapp/cli", - "version": "0.14.17", + "version": "0.14.18", "description": "HookMyApp CLI, No BS. Just go live.", "type": "module", "bin": { From 1ee64dd2cdadbf989288a0f1a308f18a8a09634e Mon Sep 17 00:00:00 2001 From: Or Dvir Date: Tue, 18 Aug 2026 08:22:17 +0300 Subject: [PATCH 05/14] AIT-420: Business plan in billing display and upsell ladder --- CHANGELOG.md | 2 +- src/__tests__/billing.test.ts | 25 +++++++++++++++++++++++-- src/api/client.ts | 2 +- src/commands/billing.ts | 13 +++++++++---- 4 files changed, 34 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cc8e7f1..4920096 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ All notable changes to `@gethookmyapp/cli` are documented here. ### Changed -- `hookmyapp billing` shows the new plans. Trial organizations see days left and actions used so far (unlimited during the trial), with a reminder to add a card. Build and Scale organizations see their action count against the plan quota, with a Scale hint when usage runs hot. Organizations on existing plans see the same output as before (AIT-420). +- `hookmyapp billing` shows the new plans. Trial organizations see days left and actions used so far (unlimited during the trial), with a reminder to add a card. Build, Scale, and Business organizations see their action count against the plan quota, with a next-plan hint when usage runs hot. Organizations on existing plans see the same output as before (AIT-420). - `hookmyapp billing upgrade` for a trial that ended offers the plan your usage qualifies you for, with no plan picker. Existing plans keep the current picker (AIT-420). - `--json` output adds `plan`, `actionsUsed`, `actionsQuota` (null means unlimited), and `trial` fields. All existing fields are unchanged (AIT-420). diff --git a/src/__tests__/billing.test.ts b/src/__tests__/billing.test.ts index 4c7b6c4..03d9567 100644 --- a/src/__tests__/billing.test.ts +++ b/src/__tests__/billing.test.ts @@ -338,7 +338,27 @@ describe('billing commands', () => { expect(logged).toContain('Running hot? Scale gives you 15,000 actions for $24/month.'); }); - it('scale org: plan line with comma-formatted quota, no upsell', async () => { + it('business org: plan line with comma-formatted quota, no upsell (top tier)', async () => { + mockSubAndUsage( + { + status: 'active', + plan: { slug: 'business', name: 'Business', messages: 0 }, + usageUnit: 'actions', + actionsUsed: 90123, + actionsQuota: 100000, + unlimited: false, + trial: null, + }, + { totalMessages: 0, limit: 0, percentage: 0 }, + ); + + await billingStatus({ human: true }); + + const logged = mockConsoleLog.mock.calls.map((c) => String(c[0])).join('\n'); + expect(logged).toBe('Plan: Business — 90,123/100,000 actions this period'); + }); + + it('scale org running hot: upsell hint points at Business', async () => { mockSubAndUsage( { status: 'active', @@ -355,7 +375,8 @@ describe('billing commands', () => { await billingStatus({ human: true }); const logged = mockConsoleLog.mock.calls.map((c) => String(c[0])).join('\n'); - expect(logged).toBe('Plan: Scale — 12,406/15,000 actions this period'); + expect(logged).toContain('Plan: Scale — 12,406/15,000 actions this period'); + expect(logged).toContain('Running hot? Business gives you 100,000 actions for $97/month.'); }); it('legacy org (no usageUnit) is untouched: no eligibility call, existing output shape', async () => { diff --git a/src/api/client.ts b/src/api/client.ts index 1ddccaf..7820eba 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -410,7 +410,7 @@ export async function apiClient( // scoped so the CLI can call it before deciding whether to show a picker at // all (Task 3). export interface BillingEligibility { - eligiblePlan: 'build' | 'scale'; + eligiblePlan: 'build' | 'scale' | 'business'; trialActions: number; trialStatus: 'not_started' | 'active' | 'expired'; } diff --git a/src/commands/billing.ts b/src/commands/billing.ts index 3c0c86e..fb03c42 100644 --- a/src/commands/billing.ts +++ b/src/commands/billing.ts @@ -142,10 +142,14 @@ export async function billingManage(opts: { json?: boolean } = {}): Promise = { +const UPSELL_HINTS: Record = { + build: 'Running hot? Scale gives you 15,000 actions for $24/month.', + scale: 'Running hot? Business gives you 100,000 actions for $97/month.', +}; +const ELIGIBLE_PLAN_DISPLAY: Record<'build' | 'scale' | 'business', { name: string; priceLabel: string }> = { build: { name: 'Build', priceLabel: '$1/month' }, scale: { name: 'Scale', priceLabel: '$24/month' }, + business: { name: 'Business', priceLabel: '$97/month' }, }; /** Money-model-v2 org (`sub.usageUnit === 'actions'`) status rendering — @@ -191,10 +195,11 @@ async function printMoneyModelStatus(orgPublicId: string, sub: BillingSubscripti const quotaLabel = quota === null || quota === undefined ? 'unlimited' : quota.toLocaleString('en-US'); console.log(`Plan: ${sub.plan.name} — ${used}/${quotaLabel} actions this period`); - if (typeof quota === 'number' && quota > 0 && sub.plan.slug !== 'scale') { + const upsellHint = UPSELL_HINTS[sub.plan.slug]; + if (typeof quota === 'number' && quota > 0 && upsellHint) { const usedRatio = (sub.actionsUsed ?? 0) / quota; if (usedRatio >= 0.75) { - console.log(SCALE_UPSELL_HINT); + console.log(upsellHint); } } } From 1146d3844b909bbac7c5d27b21762fdd160ccbac Mon Sep 17 00:00:00 2001 From: Or Dvir Date: Tue, 18 Aug 2026 14:58:09 +0300 Subject: [PATCH 06/14] AIT-420: Business-trial copy --- CHANGELOG.md | 2 +- src/__tests__/billing.test.ts | 22 +++++++++++----------- src/commands/billing.ts | 22 +++++++++++++--------- 3 files changed, 25 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4920096..5065f9b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ All notable changes to `@gethookmyapp/cli` are documented here. ### Changed -- `hookmyapp billing` shows the new plans. Trial organizations see days left and actions used so far (unlimited during the trial), with a reminder to add a card. Build, Scale, and Business organizations see their action count against the plan quota, with a next-plan hint when usage runs hot. Organizations on existing plans see the same output as before (AIT-420). +- `hookmyapp billing` shows the new plans. The trial starts at signup on Business (7 days, 100,000 actions, no card needed): trial organizations see days left and actions used against that quota, with a reminder to add a card. Build, Scale, and Business organizations see their action count against the plan quota, with a next-plan hint when usage runs hot. Organizations on existing plans see the same output as before (AIT-420). - `hookmyapp billing upgrade` for a trial that ended offers the plan your usage qualifies you for, with no plan picker. Existing plans keep the current picker (AIT-420). - `--json` output adds `plan`, `actionsUsed`, `actionsQuota` (null means unlimited), and `trial` fields. All existing fields are unchanged (AIT-420). diff --git a/src/__tests__/billing.test.ts b/src/__tests__/billing.test.ts index 03d9567..48e89e7 100644 --- a/src/__tests__/billing.test.ts +++ b/src/__tests__/billing.test.ts @@ -228,7 +228,7 @@ describe('billing commands', () => { const BILLING_URL = `https://app.test/org/${ORG_PUBLIC_ID}/billing`; - it('trial not started: exact copy, no channel yet', async () => { + it('trial not started: exact copy, no false channel-connect claim', async () => { mockSubAndUsage( { status: 'trialing', @@ -245,19 +245,19 @@ describe('billing commands', () => { await billingStatus({ human: true }); const logged = mockConsoleLog.mock.calls.map((c) => String(c[0])).join('\n'); - expect(logged).toBe('Plan: Free trial — starts when you connect your first channel'); + expect(logged).toBe('Plan: Free trial, not started yet.'); expect(mockedGetBillingEligibility).not.toHaveBeenCalled(); }); - it('trial active: days left, actions so far, add-card hint', async () => { + it('trial active: days left, actions of quota from the API, add-card hint', async () => { mockSubAndUsage( { status: 'trialing', - plan: { slug: 'build', name: 'Build', messages: 0 }, + plan: { slug: 'business', name: 'Business', messages: 0 }, usageUnit: 'actions', actionsUsed: 37, - actionsQuota: null, - unlimited: true, + actionsQuota: 100000, + unlimited: false, trial: { status: 'active', endsAt: '2026-08-22T00:00:00.000Z', daysLeft: 4 }, }, { totalMessages: 0, limit: 0, percentage: 0 }, @@ -266,7 +266,7 @@ describe('billing commands', () => { await billingStatus({ human: true }); const logged = mockConsoleLog.mock.calls.map((c) => String(c[0])).join('\n'); - expect(logged).toContain('Plan: Free trial — 4 days left · 37 actions so far (unlimited during trial)'); + expect(logged).toContain('Free trial: 4 days left · 37 of 100,000 actions'); expect(logged).toContain(`Add a card so nothing stops when the trial ends: ${BILLING_URL}`); }); @@ -274,10 +274,10 @@ describe('billing commands', () => { mockSubAndUsage( { status: 'trialing', - plan: { slug: 'build', name: 'Build', messages: 0 }, + plan: { slug: 'business', name: 'Business', messages: 0 }, usageUnit: 'actions', actionsUsed: 12, - actionsQuota: null, + actionsQuota: 100000, unlimited: false, trial: { status: 'expired', endsAt: '2026-08-10T00:00:00.000Z', daysLeft: 0 }, }, @@ -292,8 +292,8 @@ describe('billing commands', () => { await billingStatus({ human: true }); const logged = mockConsoleLog.mock.calls.map((c) => String(c[0])).join('\n'); - expect(logged).toContain('Your trial ended — channels are paused.'); - expect(logged).toContain(`Resume on Build ($1/month): ${BILLING_URL}`); + expect(logged).toContain('Your trial ended. Channels are paused.'); + expect(logged).toContain(`Add a card to resume on Build ($1/month): ${BILLING_URL}`); expect(mockedGetBillingEligibility).toHaveBeenCalledWith(ORG_PUBLIC_ID); }); diff --git a/src/commands/billing.ts b/src/commands/billing.ts index fb03c42..38b216f 100644 --- a/src/commands/billing.ts +++ b/src/commands/billing.ts @@ -159,16 +159,20 @@ async function printMoneyModelStatus(orgPublicId: string, sub: BillingSubscripti if (sub.trial) { if (sub.trial.status === 'not_started') { - console.log('Plan: Free trial — starts when you connect your first channel'); + // Dead in practice (AIT-420 Task 3 provisions the Business trial at + // signup, before any CLI call can observe this org), kept only + // because the API contract still types the state defensively. + console.log('Plan: Free trial, not started yet.'); return; } if (sub.trial.status === 'active') { const daysLeft = sub.trial.daysLeft ?? 0; - const actionsUsed = sub.actionsUsed ?? 0; - console.log( - `Plan: Free trial — ${daysLeft} day${daysLeft === 1 ? '' : 's'} left · ` + - `${actionsUsed.toLocaleString('en-US')} actions so far (unlimited during trial)`, - ); + const used = (sub.actionsUsed ?? 0).toLocaleString('en-US'); + // Quota always comes from the API, never hardcoded — the trial is + // 100,000 actions on Business today, but this line must not assume it. + const quota = sub.actionsQuota; + const quotaLabel = quota === null || quota === undefined ? 'unlimited' : quota.toLocaleString('en-US'); + console.log(`Free trial: ${daysLeft} days left · ${used} of ${quotaLabel} actions`); console.log(`Add a card so nothing stops when the trial ends: ${billingUrl}`); return; } @@ -178,11 +182,11 @@ async function printMoneyModelStatus(orgPublicId: string, sub: BillingSubscripti // which plan the org is allowed to resume onto. const eligibility = await getBillingEligibility(orgPublicId); const display = eligibility ? ELIGIBLE_PLAN_DISPLAY[eligibility.eligiblePlan] : undefined; - console.log('Your trial ended — channels are paused.'); + console.log('Your trial ended. Channels are paused.'); console.log( display - ? `Resume on ${display.name} (${display.priceLabel}): ${billingUrl}` - : `Resume your plan: ${billingUrl}`, + ? `Add a card to resume on ${display.name} (${display.priceLabel}): ${billingUrl}` + : `Add a card to resume: ${billingUrl}`, ); return; } From ca7a0a695d799927c00a2d3524eaaf8a63235032 Mon Sep 17 00:00:00 2001 From: Or Dvir Date: Tue, 18 Aug 2026 15:04:15 +0300 Subject: [PATCH 07/14] AIT-420: trial line never claims unlimited when quota is absent --- src/commands/billing.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/commands/billing.ts b/src/commands/billing.ts index 38b216f..29eecfd 100644 --- a/src/commands/billing.ts +++ b/src/commands/billing.ts @@ -171,8 +171,14 @@ async function printMoneyModelStatus(orgPublicId: string, sub: BillingSubscripti // Quota always comes from the API, never hardcoded — the trial is // 100,000 actions on Business today, but this line must not assume it. const quota = sub.actionsQuota; - const quotaLabel = quota === null || quota === undefined ? 'unlimited' : quota.toLocaleString('en-US'); - console.log(`Free trial: ${daysLeft} days left · ${used} of ${quotaLabel} actions`); + // A missing quota drops the "of X" clause rather than claiming + // unlimited: the trial is finite, so guessing the wrong way is worse + // than saying less. + console.log( + quota === null || quota === undefined + ? `Free trial: ${daysLeft} days left · ${used} actions used` + : `Free trial: ${daysLeft} days left · ${used} of ${quota.toLocaleString('en-US')} actions`, + ); console.log(`Add a card so nothing stops when the trial ends: ${billingUrl}`); return; } From 3b6f1a0626c6fd02e2ca4628d35697636e9677eb Mon Sep 17 00:00:00 2001 From: Or Dvir Date: Tue, 18 Aug 2026 21:08:38 +0300 Subject: [PATCH 08/14] AIT-420: say credit card, not card --- src/__tests__/billing.test.ts | 4 ++-- src/commands/billing.ts | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/__tests__/billing.test.ts b/src/__tests__/billing.test.ts index 48e89e7..fcd6066 100644 --- a/src/__tests__/billing.test.ts +++ b/src/__tests__/billing.test.ts @@ -267,7 +267,7 @@ describe('billing commands', () => { const logged = mockConsoleLog.mock.calls.map((c) => String(c[0])).join('\n'); expect(logged).toContain('Free trial: 4 days left · 37 of 100,000 actions'); - expect(logged).toContain(`Add a card so nothing stops when the trial ends: ${BILLING_URL}`); + expect(logged).toContain(`Add a credit card so nothing stops when the trial ends: ${BILLING_URL}`); }); it('trial expired: paused copy + resume line from eligibility plan/price', async () => { @@ -293,7 +293,7 @@ describe('billing commands', () => { const logged = mockConsoleLog.mock.calls.map((c) => String(c[0])).join('\n'); expect(logged).toContain('Your trial ended. Channels are paused.'); - expect(logged).toContain(`Add a card to resume on Build ($1/month): ${BILLING_URL}`); + expect(logged).toContain(`Add a credit card to resume on Build ($1/month): ${BILLING_URL}`); expect(mockedGetBillingEligibility).toHaveBeenCalledWith(ORG_PUBLIC_ID); }); diff --git a/src/commands/billing.ts b/src/commands/billing.ts index 29eecfd..8cb4a23 100644 --- a/src/commands/billing.ts +++ b/src/commands/billing.ts @@ -179,7 +179,7 @@ async function printMoneyModelStatus(orgPublicId: string, sub: BillingSubscripti ? `Free trial: ${daysLeft} days left · ${used} actions used` : `Free trial: ${daysLeft} days left · ${used} of ${quota.toLocaleString('en-US')} actions`, ); - console.log(`Add a card so nothing stops when the trial ends: ${billingUrl}`); + console.log(`Add a credit card so nothing stops when the trial ends: ${billingUrl}`); return; } if (sub.trial.status === 'expired') { @@ -191,8 +191,8 @@ async function printMoneyModelStatus(orgPublicId: string, sub: BillingSubscripti console.log('Your trial ended. Channels are paused.'); console.log( display - ? `Add a card to resume on ${display.name} (${display.priceLabel}): ${billingUrl}` - : `Add a card to resume: ${billingUrl}`, + ? `Add a credit card to resume on ${display.name} (${display.priceLabel}): ${billingUrl}` + : `Add a credit card to resume: ${billingUrl}`, ); return; } From 10b5d185fd051cc307e87f97362b86c9a357fbf5 Mon Sep 17 00:00:00 2001 From: Or Dvir Date: Wed, 19 Aug 2026 12:39:08 +0300 Subject: [PATCH 09/14] AIT-420: a trial does not count as a completed checkout An org in a v2 trial already reads plan.slug 'business' / status 'trialing' before it pays, so the post-checkout poll matched on its very first tick: the CLI printed the upgrade line about five seconds after opening the browser, whether or not the user finished paying. The trial path now waits for the observable transition instead, which is the trial going null once payment lands. The test starts from an active trial, never completes checkout, and fails against the old predicate. --- src/__tests__/billing.test.ts | 59 +++++++++++++++++++++++++++++++++++ src/commands/billing.ts | 26 +++++++++++++-- 2 files changed, 82 insertions(+), 3 deletions(-) diff --git a/src/__tests__/billing.test.ts b/src/__tests__/billing.test.ts index fcd6066..aab39ea 100644 --- a/src/__tests__/billing.test.ts +++ b/src/__tests__/billing.test.ts @@ -684,6 +684,65 @@ describe('billing commands', () => { expect(mockedOpen).toHaveBeenCalledWith('https://checkout.stripe.com/x'); }); + // Codex + CodeRabbit, PR #61: an ACTIVE trial already reads + // plan.slug 'business' / status 'trialing' BEFORE checkout, so the old + // "not free and live" poll predicate matched on its first tick and printed + // the upgrade line ~5s after opening the browser, whether or not the user + // paid. Completion now requires the trial to actually clear. + it('active trial org: does NOT confirm the upgrade while the trial is still open', async () => { + 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('monthly' as never); + mockedGetBillingEligibility.mockResolvedValueOnce({ + eligiblePlan: 'scale', + trialActions: 900, + trialStatus: 'active', + }); + + // The subscription NEVER changes: the user abandoned Stripe Checkout. + // A trialing v2 org looks "upgraded" to the naive predicate the whole time. + mockedApiClient.mockImplementation(async (path: string) => { + if (path === SUBSCRIPTION_PATH) { + return { + status: 'trialing', + usageUnit: 'actions', + actionsUsed: 900, + actionsQuota: 100000, + plan: { slug: 'business', name: 'Business', messages: 0 }, + trial: { status: 'active', endsAt: '2026-08-22T00:00:00.000Z', daysLeft: 4 }, + }; + } + if (path === '/workspaces') return WORKSPACES; + if (path === '/plans') throw new Error('unexpected /plans fetch on eligibility-locked path'); + if (path === CHECKOUT_PATH) return { url: 'https://checkout.stripe.com/x' }; + throw new Error(`unexpected path: ${path}`); + }); + + await import('../index.js'); + + vi.useFakeTimers(); + try { + const run = billingUpgrade(); + run.catch(() => {}); // never settles here; keep the rejection handled + // Small steps, same reason advanceUntilSettled uses them: one big jump + // races ahead of the pending chain before the first poll timer exists. + // 600 x 50ms is well past several poll intervals — the old predicate + // returned on the FIRST one. + for (let i = 0; i < 600; i++) await vi.advanceTimersByTimeAsync(50); + + const logged = mockConsoleLog.mock.calls.map((c) => String(c[0])).join('\n'); + expect(logged).toContain('Waiting for payment confirmation'); + expect(logged).not.toContain('Upgraded to'); + } finally { + process.stdout.isTTY = origTTY; + process.stdin.isTTY = origStdinTTY; + vi.useRealTimers(); + } + }); + it('legacy active org still gets the plan picker (unchanged)', async () => { await runPaidPathDeclining('active'); diff --git a/src/commands/billing.ts b/src/commands/billing.ts index 8cb4a23..c675533 100644 --- a/src/commands/billing.ts +++ b/src/commands/billing.ts @@ -87,7 +87,27 @@ const UPGRADE_POLL_HINT_EVERY_MS = 60_000; * (expired auth, revoked permission, client-outdated) rethrow — polling * forever on those would tell the user to "finish checkout" while the CLI * itself is the thing that's broken. */ -async function pollForUpgrade(orgPublicId: string): Promise { +/** Has the thing we opened Stripe for actually happened? + * + * The default answer ("on a paid plan, and live") is right for a legacy org, + * which starts from `free`. It is WRONG for a money-model-v2 org in a trial: + * that org already reads `plan.slug: 'business'`, `status: 'trialing'` BEFORE + * checkout, so the poll returned on its first tick and printed + * "✓ Upgraded to Business" about five seconds after opening the browser — + * even if the user closed the tab and paid nothing (Codex + CodeRabbit, + * cli PR #61). The trial path passes `trialSettled` instead, which requires + * the observable transition: `trial` goes null once `paidAt` lands. */ +type UpgradeComplete = (sub: BillingSubscription) => boolean; + +const onPaidPlan: UpgradeComplete = (sub) => + sub.plan.slug !== 'free' && ['active', 'trialing'].includes(sub.status); + +const trialSettled: UpgradeComplete = (sub) => onPaidPlan(sub) && !sub.trial; + +async function pollForUpgrade( + orgPublicId: string, + isComplete: UpgradeComplete = onPaidPlan, +): Promise { const startedAt = Date.now(); let lastHintAt = startedAt; for (;;) { @@ -111,7 +131,7 @@ async function pollForUpgrade(orgPublicId: string): Promise if (!transient) throw err; sub = null; } - if (sub && sub.plan.slug !== 'free' && ['active', 'trialing'].includes(sub.status)) { + if (sub && isComplete(sub)) { return sub; } if (Date.now() - lastHintAt >= UPGRADE_POLL_HINT_EVERY_MS) { @@ -420,7 +440,7 @@ async function checkoutEligiblePlan(orgPublicId: string): Promise { await open(data.url); console.log('Waiting for payment confirmation... (Ctrl+C to cancel)'); - const upgraded = await pollForUpgrade(orgPublicId); + const upgraded = await pollForUpgrade(orgPublicId, trialSettled); console.log(describeUpgradedPlan(upgraded)); } From bddabdcfd1ebf3fccde677537269c31a906ad5dc Mon Sep 17 00:00:00 2001 From: Or Dvir Date: Wed, 19 Aug 2026 13:15:29 +0300 Subject: [PATCH 10/14] AIT-420: a message-metered org keeps message output usageUnit 'messages' is a valid variant, and such an org carries actionsUsed/actionsQuota 0. Routing it to the action renderer printed '0 of 0 actions' in place of its real usage. Branch on 'actions'. --- src/__tests__/billing.test.ts | 23 +++++++++++++++++++++++ src/commands/billing.ts | 7 ++++++- 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/src/__tests__/billing.test.ts b/src/__tests__/billing.test.ts index aab39ea..a1be68d 100644 --- a/src/__tests__/billing.test.ts +++ b/src/__tests__/billing.test.ts @@ -270,6 +270,29 @@ describe('billing commands', () => { expect(logged).toContain(`Add a credit card so nothing stops when the trial ends: ${BILLING_URL}`); }); + // Codex, PR #61: usageUnit 'messages' is a valid variant. Such an org has + // actionsUsed/actionsQuota 0, so routing it to the action renderer printed + // "0 of 0 actions" in place of its real message usage. + it('v2-flagged org still on the MESSAGE meter keeps the message output', async () => { + mockSubAndUsage( + { + status: 'active', + plan: { slug: 'starter', name: 'Build', messages: 30000 }, + usageUnit: 'messages', + actionsUsed: 0, + actionsQuota: 0, + unlimited: false, + }, + { totalMessages: 1234, limit: 30000, percentage: 4 }, + ); + + await billingStatus({ human: true }); + + const logged = mockConsoleLog.mock.calls.map((c) => String(c[0])).join('\n'); + expect(logged).toContain('plan: Build'); + expect(logged).not.toContain('actions this period'); + }); + it('trial expired: paused copy + resume line from eligibility plan/price', async () => { mockSubAndUsage( { diff --git a/src/commands/billing.ts b/src/commands/billing.ts index c675533..b64b7f5 100644 --- a/src/commands/billing.ts +++ b/src/commands/billing.ts @@ -250,7 +250,12 @@ export async function billingStatus(opts: { json?: boolean; human?: boolean } = // orgs (usageUnit undefined) get byte-identical output to before this // feature existed; money-model-v2 orgs (usageUnit 'actions'/'messages') // get the new trial/action-plan rendering. - const isMoneyModelV2 = sub.usageUnit !== undefined; + // 'actions', not merely "usageUnit is present": a v2-flagged org still on the + // MESSAGE meter reports usageUnit 'messages' with actionsUsed/actionsQuota 0, + // and the action renderer would print "0 of 0 actions" instead of their real + // message usage (Codex, PR #61). Message-metered orgs stay on the legacy + // renderer whether or not the flag is on for them. + const isMoneyModelV2 = sub.usageUnit === 'actions'; if (isJson) { if (isMoneyModelV2) { From 74d147e990add7bfd73fc9214f4b15262cff1dcf Mon Sep 17 00:00:00 2001 From: Or Dvir Date: Wed, 19 Aug 2026 13:20:39 +0300 Subject: [PATCH 11/14] AIT-420: a paid action plan never enters the legacy picker GET /plans serves the legacy catalog only. A paid v2 org whose trial had resolved fell through to the terminal picker, which would offer it starter/growth/pro at legacy prices and then attempt a cross-generation plan change. It now takes the same Billing-page escape hatch the code already uses for states the terminal cannot describe honestly. --- src/__tests__/billing.test.ts | 38 +++++++++++++++++++++++++++++++++++ src/commands/billing.ts | 6 ++++++ 2 files changed, 44 insertions(+) diff --git a/src/__tests__/billing.test.ts b/src/__tests__/billing.test.ts index a1be68d..0f7a404 100644 --- a/src/__tests__/billing.test.ts +++ b/src/__tests__/billing.test.ts @@ -766,6 +766,44 @@ describe('billing commands', () => { } }); + // Codex, PR #61: /plans serves the LEGACY catalog only, so a paid v2 org + // reaching the terminal picker would be offered starter/growth/pro at + // legacy prices and then attempt a cross-generation plan change. + it('paid action-metered org: opens Billing instead of the legacy plan picker', async () => { + const origTTY = process.stdout.isTTY; + const origStdinTTY = process.stdin.isTTY; + process.stdout.isTTY = true; + process.stdin.isTTY = true; + const inq = await import('@inquirer/prompts'); + mockedApiClient.mockImplementation(async (path: string) => { + if (path === SUBSCRIPTION_PATH) { + return { + status: 'active', + billingInterval: 'monthly', + usageUnit: 'actions', + actionsUsed: 900, + actionsQuota: 15000, + unlimited: false, + trial: null, + plan: { slug: 'scale', name: 'Scale', messages: 0 }, + }; + } + if (path === '/workspaces') return WORKSPACES; + if (path === '/plans') throw new Error('unexpected /plans fetch for an action-metered org'); + throw new Error(`unexpected path: ${path}`); + }); + + try { + await billingUpgrade(); + } finally { + process.stdout.isTTY = origTTY; + process.stdin.isTTY = origStdinTTY; + } + + expect(mockedOpen).toHaveBeenCalledWith(expect.stringContaining('billing')); + expect(inq.select).not.toHaveBeenCalled(); + }); + it('legacy active org still gets the plan picker (unchanged)', async () => { await runPaidPathDeclining('active'); diff --git a/src/commands/billing.ts b/src/commands/billing.ts index b64b7f5..e6b32f0 100644 --- a/src/commands/billing.ts +++ b/src/commands/billing.ts @@ -509,6 +509,12 @@ export async function billingUpgrade(opts: { json?: boolean } = {}): Promise Date: Wed, 19 Aug 2026 13:27:07 +0300 Subject: [PATCH 12/14] AIT-420: every action plan leaves the legacy picker, and a scheduled cancel still shows The first fix guarded only the active case, so a canceled, incomplete, or unpaid action-metered org still fell through to the terminal picker and would be offered legacy tiers at legacy prices. The check now runs before the active-status test and covers all of them. billing status also hid a scheduled cancellation from action orgs: the action renderer returned before the shared warning ever ran. --- src/__tests__/billing.test.ts | 60 +++++++++++++++++++++++++++++++++++ src/commands/billing.ts | 25 +++++++++++---- 2 files changed, 79 insertions(+), 6 deletions(-) diff --git a/src/__tests__/billing.test.ts b/src/__tests__/billing.test.ts index 0f7a404..8e14c3e 100644 --- a/src/__tests__/billing.test.ts +++ b/src/__tests__/billing.test.ts @@ -293,6 +293,29 @@ describe('billing commands', () => { expect(logged).not.toContain('actions this period'); }); + // Codex, PR #61: the action renderer returned before the shared warning, so + // a scheduled cancellation was silently hidden from these organizations. + it('action org with a scheduled cancellation still says so', async () => { + mockSubAndUsage( + { + status: 'active', + plan: { slug: 'scale', name: 'Scale', messages: 0 }, + usageUnit: 'actions', + actionsUsed: 900, + actionsQuota: 15000, + unlimited: false, + trial: null, + cancelAtPeriodEnd: true, + }, + { totalMessages: 0, limit: 0, percentage: 0 }, + ); + + await billingStatus({ human: true }); + + const logged = mockConsoleLog.mock.calls.map((c) => String(c[0])).join('\n'); + expect(logged).toContain('cancel at period end'); + }); + it('trial expired: paused copy + resume line from eligibility plan/price', async () => { mockSubAndUsage( { @@ -804,6 +827,43 @@ describe('billing commands', () => { expect(inq.select).not.toHaveBeenCalled(); }); + // Codex, PR #61 round 2: the first fix guarded only the ACTIVE case, so a + // canceled/incomplete/unpaid action org still reached the legacy picker. + it('CANCELED action-metered org: still opens Billing, never the legacy picker', async () => { + const origTTY = process.stdout.isTTY; + const origStdinTTY = process.stdin.isTTY; + process.stdout.isTTY = true; + process.stdin.isTTY = true; + const inq = await import('@inquirer/prompts'); + mockedApiClient.mockImplementation(async (path: string) => { + if (path === SUBSCRIPTION_PATH) { + return { + status: 'canceled', + billingInterval: 'monthly', + usageUnit: 'actions', + actionsUsed: 40, + actionsQuota: 200, + unlimited: false, + trial: null, + plan: { slug: 'build', name: 'Build', messages: 0 }, + }; + } + if (path === '/workspaces') return WORKSPACES; + if (path === '/plans') throw new Error('unexpected /plans fetch for an action-metered org'); + throw new Error(`unexpected path: ${path}`); + }); + + try { + await billingUpgrade(); + } finally { + process.stdout.isTTY = origTTY; + process.stdin.isTTY = origStdinTTY; + } + + expect(mockedOpen).toHaveBeenCalledWith(expect.stringContaining('billing')); + expect(inq.select).not.toHaveBeenCalled(); + }); + it('legacy active org still gets the plan picker (unchanged)', async () => { await runPaidPathDeclining('active'); diff --git a/src/commands/billing.ts b/src/commands/billing.ts index e6b32f0..a83499a 100644 --- a/src/commands/billing.ts +++ b/src/commands/billing.ts @@ -278,6 +278,12 @@ export async function billingStatus(opts: { json?: boolean; human?: boolean } = if (isMoneyModelV2) { await printMoneyModelStatus(orgPublicId, sub); + // The action renderer returns early, so the shared warning below never ran + // for these organizations: `billing status` showed usage and silently hid + // that service is scheduled to end (Codex, PR #61). + if (sub.cancelAtPeriodEnd === true) { + console.log('\n' + c.warn('Subscription will cancel at period end.')); + } return; } @@ -494,6 +500,19 @@ export async function billingUpgrade(opts: { json?: boolean } = {}): Promise Date: Wed, 19 Aug 2026 13:41:44 +0300 Subject: [PATCH 13/14] AIT-420: a suspended action plan does not look live, and an absent quota is not unlimited Every action-metered subscription reaches the action renderer now, so a past_due, canceled, incomplete, or unpaid plan was printing exactly like a running one. It says the status when the subscription is not plainly running. --json coerced an absent actionsQuota to null, and null is the published contract for UNLIMITED -- a machine consumer would read missing data as no cap. The field is omitted when the API sent nothing; a real null still means unlimited. package-lock.json carried the pre-bump version. --- package-lock.json | 4 ++-- src/__tests__/billing.test.ts | 43 +++++++++++++++++++++++++++++++++++ src/commands/billing.ts | 14 +++++++++++- 3 files changed, 58 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 56b1d31..b220785 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@gethookmyapp/cli", - "version": "0.14.17", + "version": "0.14.18", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@gethookmyapp/cli", - "version": "0.14.17", + "version": "0.14.18", "license": "MIT", "dependencies": { "@inquirer/prompts": "^7.0.0", diff --git a/src/__tests__/billing.test.ts b/src/__tests__/billing.test.ts index 8e14c3e..fb2c4ef 100644 --- a/src/__tests__/billing.test.ts +++ b/src/__tests__/billing.test.ts @@ -316,6 +316,49 @@ describe('billing commands', () => { expect(logged).toContain('cancel at period end'); }); + // Codex, PR #61: every action-metered subscription reaches this renderer, + // so a canceled or past_due plan was printing exactly like a live one. + it('past_due action org: says the subscription is not running', async () => { + mockSubAndUsage( + { + status: 'past_due', + plan: { slug: 'scale', name: 'Scale', messages: 0 }, + usageUnit: 'actions', + actionsUsed: 900, + actionsQuota: 15000, + unlimited: false, + trial: null, + }, + { totalMessages: 0, limit: 0, percentage: 0 }, + ); + + await billingStatus({ human: true }); + + const logged = mockConsoleLog.mock.calls.map((c) => String(c[0])).join('\n'); + expect(logged).toContain('past_due'); + }); + + // `null` means unlimited in the published JSON contract, so an ABSENT quota + // must not be coerced to null (Codex, PR #61). + it('--json omits actionsQuota entirely when the API did not send one', async () => { + mockSubAndUsage( + { + status: 'active', + plan: { slug: 'scale', name: 'Scale', messages: 0 }, + usageUnit: 'actions', + actionsUsed: 900, + unlimited: false, + trial: null, + }, + { totalMessages: 0, limit: 0, percentage: 0 }, + ); + + await billingStatus({ json: true }); + + const payload = JSON.parse(mockConsoleLog.mock.calls.map((c) => String(c[0])).join('')); + expect(payload).not.toHaveProperty('actionsQuota'); + }); + it('trial expired: paused copy + resume line from eligibility plan/price', async () => { mockSubAndUsage( { diff --git a/src/commands/billing.ts b/src/commands/billing.ts index a83499a..5f13a92 100644 --- a/src/commands/billing.ts +++ b/src/commands/billing.ts @@ -225,6 +225,14 @@ async function printMoneyModelStatus(orgPublicId: string, sub: BillingSubscripti const quotaLabel = quota === null || quota === undefined ? 'unlimited' : quota.toLocaleString('en-US'); console.log(`Plan: ${sub.plan.name} — ${used}/${quotaLabel} actions this period`); + // Every action-metered subscription reaches this renderer, including + // past_due / canceled / incomplete / unpaid. Printing only plan and usage + // made a suspended or canceled plan look live (Codex, PR #61), so any state + // that is not plainly running says so. + if (sub.status !== 'active' && sub.status !== 'trialing') { + console.log(c.warn(`Subscription status: ${sub.status}.`)); + } + const upsellHint = UPSELL_HINTS[sub.plan.slug]; if (typeof quota === 'number' && quota > 0 && upsellHint) { const usedRatio = (sub.actionsUsed ?? 0) / quota; @@ -265,7 +273,11 @@ export async function billingStatus(opts: { json?: boolean; human?: boolean } = usage, plan: sub.plan.name, actionsUsed: sub.actionsUsed ?? null, - actionsQuota: sub.actionsQuota ?? null, + // `null` is the published contract for UNLIMITED, so an ABSENT quota + // must not be coerced into it: that would tell a machine consumer the + // org has no cap when we simply did not receive one (Codex, PR #61). + // A real API null still passes through as unlimited. + ...(sub.actionsQuota === undefined ? {} : { actionsQuota: sub.actionsQuota }), trial: sub.trial ? { daysLeft: sub.trial.daysLeft, endsAt: sub.trial.endsAt } : null, }, { json: true }, From 1d1ad798b8216a2f196a54a31cf504fca23b6879 Mon Sep 17 00:00:00 2001 From: Or Dvir Date: Wed, 19 Aug 2026 13:45:30 +0300 Subject: [PATCH 14/14] AIT-420: an absent quota is unknown, never unlimited The human renderer and describeUpgradedPlan both printed 'unlimited' for a quota the API never sent, which promises an uncapped plan. Only a real null means unlimited, the same distinction --json already makes. The local billingStatus type in the test file also omitted json, which broke the typecheck. --- src/__tests__/billing.test.ts | 26 ++++++++++++++++++++++++-- src/commands/billing.ts | 8 ++++++-- 2 files changed, 30 insertions(+), 4 deletions(-) diff --git a/src/__tests__/billing.test.ts b/src/__tests__/billing.test.ts index fb2c4ef..539e7a8 100644 --- a/src/__tests__/billing.test.ts +++ b/src/__tests__/billing.test.ts @@ -98,7 +98,7 @@ function mockSubAndUsage(sub: any, usage: { totalMessages: number; limit: number } describe('billing commands', () => { - let billingStatus: (opts: { human?: boolean }) => Promise; + let billingStatus: (opts: { json?: boolean; human?: boolean }) => Promise; let billingUpgrade: (opts?: { json?: boolean }) => Promise; let billingManage: () => Promise; @@ -359,6 +359,28 @@ describe('billing commands', () => { expect(payload).not.toHaveProperty('actionsQuota'); }); + // Codex, PR #61: only a real null means unlimited. An absent quota printed + // as "unlimited" falsely promised an uncapped plan. + it('human output says unknown, not unlimited, when the API sent no quota', async () => { + mockSubAndUsage( + { + status: 'active', + plan: { slug: 'scale', name: 'Scale', messages: 0 }, + usageUnit: 'actions', + actionsUsed: 900, + unlimited: false, + trial: null, + }, + { totalMessages: 0, limit: 0, percentage: 0 }, + ); + + await billingStatus({ human: true }); + + const logged = mockConsoleLog.mock.calls.map((c) => String(c[0])).join('\n'); + expect(logged).toContain('unknown'); + expect(logged).not.toContain('unlimited'); + }); + it('trial expired: paused copy + resume line from eligibility plan/price', async () => { mockSubAndUsage( { @@ -916,7 +938,7 @@ describe('billing commands', () => { }); describe('billing commands — npx prefix roll-out (cliCommandPrefix)', () => { - let billingStatus: (opts: { human?: boolean }) => Promise; + let billingStatus: (opts: { json?: boolean; human?: boolean }) => Promise; beforeEach(async () => { vi.resetModules(); diff --git a/src/commands/billing.ts b/src/commands/billing.ts index 5f13a92..87dbd92 100644 --- a/src/commands/billing.ts +++ b/src/commands/billing.ts @@ -222,7 +222,10 @@ async function printMoneyModelStatus(orgPublicId: string, sub: BillingSubscripti // for unlimited — never call .toLocaleString on it directly. const used = (sub.actionsUsed ?? 0).toLocaleString('en-US'); const quota = sub.actionsQuota; - const quotaLabel = quota === null || quota === undefined ? 'unlimited' : quota.toLocaleString('en-US'); + // Only a real `null` means unlimited. An ABSENT quota is unknown, and + // printing "unlimited" for it falsely promises an uncapped plan + // (Codex, PR #61) -- the same distinction the --json branch makes. + const quotaLabel = quota === null ? 'unlimited' : quota === undefined ? 'unknown' : quota.toLocaleString('en-US'); console.log(`Plan: ${sub.plan.name} — ${used}/${quotaLabel} actions this period`); // Every action-metered subscription reaches this renderer, including @@ -427,7 +430,8 @@ async function changePlanInTerminal( function describeUpgradedPlan(sub: BillingSubscription): string { if (sub.usageUnit === 'actions') { const quota = sub.actionsQuota; - const quotaLabel = quota === null || quota === undefined ? 'unlimited' : quota.toLocaleString('en-US'); + // Same distinction as the status renderer: absent is unknown, null is unlimited. + const quotaLabel = quota === null ? 'unlimited' : quota === undefined ? 'unknown' : quota.toLocaleString('en-US'); return `✓ Upgraded to ${sub.plan.name} (${quotaLabel} actions/mo).`; } return `✓ Upgraded to ${sub.plan.name} (${sub.plan.messages.toLocaleString('en-US')} messages/mo).`;