diff --git a/CHANGELOG.md b/CHANGELOG.md index 28c2163..5065f9b 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. 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). + ## 0.14.16 — 2026-08-14 ### Fixed 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/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": { diff --git a/src/__tests__/billing.test.ts b/src/__tests__/billing.test.ts index 52ea4ce..539e7a8 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'; @@ -96,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; @@ -104,6 +106,7 @@ describe('billing commands', () => { vi.resetModules(); mockedApiClient.mockReset(); mockedOpen.mockReset(); + mockedGetBillingEligibility.mockReset(); mockExit.mockClear(); mockConsoleError.mockClear(); mockConsoleLog.mockClear(); @@ -215,6 +218,316 @@ 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 false channel-connect claim', 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, not started yet.'); + expect(mockedGetBillingEligibility).not.toHaveBeenCalled(); + }); + + it('trial active: days left, actions of quota from the API, add-card hint', async () => { + mockSubAndUsage( + { + status: 'trialing', + plan: { slug: 'business', name: 'Business', messages: 0 }, + usageUnit: 'actions', + actionsUsed: 37, + actionsQuota: 100000, + unlimited: false, + 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('Free trial: 4 days left · 37 of 100,000 actions'); + 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'); + }); + + // 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'); + }); + + // 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'); + }); + + // 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( + { + status: 'trialing', + plan: { slug: 'business', name: 'Business', messages: 0 }, + usageUnit: 'actions', + actionsUsed: 12, + actionsQuota: 100000, + 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(`Add a credit card to 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('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', + 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).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 () => { + 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 @@ -413,11 +726,219 @@ 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'); + }); + + // 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(); + } + }); + + // 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(); + }); + + // 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'); + + expect(mockedGetBillingEligibility).not.toHaveBeenCalled(); + }); }); }); 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/__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..7820eba 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' | 'business'; + 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 diff --git a/src/commands/billing.ts b/src/commands/billing.ts index cb8b20a..87dbd92 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'; @@ -87,18 +87,36 @@ 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. */ +/** 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, -): Promise<{ plan: { slug: string; name: string; messages: number }; status: string }> { + isComplete: UpgradeComplete = onPaidPlan, +): 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 @@ -113,7 +131,7 @@ async function pollForUpgrade( 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) { @@ -140,23 +158,150 @@ export async function billingManage(opts: { json?: boolean } = {}): Promise = { + 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 — + * 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') { + // 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 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; + // 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 credit 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 + ? `Add a credit card to resume on ${display.name} (${display.priceLabel}): ${billingUrl}` + : `Add a credit card to resume: ${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; + // 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 + // 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; + if (usedRatio >= 0.75) { + console.log(upsellHint); + } + } +} + 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. + // '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) { + output( + { + subscription: sub, + usage, + plan: sub.plan.name, + actionsUsed: sub.actionsUsed ?? 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 }, + ); + return; + } output({ subscription: sub, usage }, { json: true }); return; } + 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; + } + const plan = sub.plan.name; const status = sub.status; const interval = sub.billingInterval ?? 'n/a'; @@ -279,6 +424,53 @@ 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; + // 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).`; +} + +/** 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, trialSettled); + 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, @@ -310,7 +502,33 @@ export async function billingUpgrade(opts: { json?: boolean } = {}): Promise