diff --git a/.gitignore b/.gitignore index a1f4931..42e7115 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,7 @@ coverage/ !.claude/skills/ .claude/skills/* !.claude/skills/developing-insta-cli/ + +# Next.js turbopack trace output — this is not a Next app, but a stray build here +# has twice been swept into a commit by `git add -A`. +.next/ diff --git a/src/commands/billing.ts b/src/commands/billing.ts index 237006d..bbbe86a 100644 --- a/src/commands/billing.ts +++ b/src/commands/billing.ts @@ -19,7 +19,10 @@ export type BillingOverview = { } // Format the billing overview into printable lines (pure, so it's unit-testable). -export function billingLines(s: BillingOverview): string[] { +// `org` is the caller's --org, echoed into the portal hint: `billing` and `billing portal` resolve +// the target independently, so a hint that drops the flag sends someone reading org A's overview to +// org B's portal. +export function billingLines(s: BillingOverview, org?: string): string[] { const t = s.totals const lines = [ `tier: ${s.tier}`, @@ -33,7 +36,39 @@ export function billingLines(s: BillingOverview): string[] { ] if (s.subscriptionStatus) lines.push(`subscription: ${s.subscriptionStatus}`) if (s.billingStatus === 'suspended') { - lines.push('⚠ org suspended — billing limit reached; resumes next cycle (or `insta billing upgrade pro`)') + // Four causes, five messages, and every one is a dead end for the others. Tier first: only a + // free org can spend a prepaid wallet, and waiting for the next cycle genuinely fixes that one. + // (Tier, not subscriptionStatus, because rows written before non-payment suspended carry + // `unpaid` beside tier 'free' and survive with no migration.) Then the status splits the paid + // branch three ways: an invoice to settle, a subscription to replace, or — when it reads + // healthy — a suspension that outlived its cause, which is what a recovery whose compute failed + // to restart looks like, and where telling them to pay means re-settling a paid invoice. The + // replace case is the one that splits again, because enterprise has no self-serve checkout. + // + // EVERY command here carries the caller's --org. `billing` and the command being suggested + // resolve the target independently, so a hint that drops the flag acts on a different org than + // the one being read — and two of them take payment. + const flag = org ? ` --org ${org}` : '' + const lapsed = s.subscriptionStatus === 'past_due' || s.subscriptionStatus === 'unpaid' + const ended = s.subscriptionStatus === 'canceled' || s.subscriptionStatus === 'incomplete_expired' + lines.push( + s.tier === 'free' + ? `⚠ org suspended — billing limit reached; resumes next cycle (or \`insta billing upgrade pro${flag}\`)` + : lapsed + ? `⚠ org suspended — subscription payment did not go through; settle it in \`insta billing portal${flag}\`` + : ended + ? s.tier === 'enterprise' + // Per-deal, and `billing upgrade` cannot create one: naming a self-serve tier here + // would move them off the plan they negotiated. + ? '⚠ org suspended — the subscription ended; contact support to restore this plan' + // Their OWN tier, not a hardcoded one: suggesting `upgrade pro` to a Team org + // resubscribes it onto the wrong plan. + : `⚠ org suspended — the subscription ended; resubscribe with \`insta billing upgrade ${s.tier}${flag}\`` + // Deliberately claims nothing about the subscription: `incomplete` reaches here too, + // and that one is neither current nor failed. All this branch knows is that the + // suspension has no billing cause it can name. + : '⚠ org suspended — no failed payment on file; contact support', + ) } if (s.byDimension?.length) { lines.push('by dimension:') @@ -52,12 +87,16 @@ export async function billing(opts: OrgOpt & { json?: boolean }): Promise const orgId = await resolveOrgId(opts) const s = await api.request('GET', `/orgs/${orgId}/billing/overview`) if (opts.json) return printJson(s) - for (const l of billingLines(s)) info(l) + for (const l of billingLines(s, opts.org)) info(l) } // insta billing upgrade — start a Stripe Checkout to subscribe the org to a paid tier. export async function billingUpgrade(tier: string, opts: OrgOpt & { open?: boolean; json?: boolean }): Promise { - if (tier !== 'pro' && tier !== 'enterprise') die('tier must be pro|enterprise') + // pro|team, matching what POST /orgs/:orgId/billing/checkout actually accepts. This said + // pro|enterprise, which was wrong both ways: `team` is a real self-serve tier and was refused + // here, and `enterprise` is per-deal and 400s at the server. The suspension hint above now names + // the org's own tier, so a Team org was being sent to a command that rejected it. + if (tier !== 'pro' && tier !== 'team') die('tier must be pro|team') const api = await ApiClient.load() const orgId = await resolveOrgId(opts) const { url } = await api.request<{ url: string }>('POST', `/orgs/${orgId}/billing/checkout`, { tier }) diff --git a/src/index.ts b/src/index.ts index d0436c1..b8936f0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -321,7 +321,7 @@ program.command('usage').description('Usage for the current billing cycle by bil const bill = program.command('billing').description('Current billing cycle overview (tier / used / included / overage / credits / forecast + per-dimension & per-project breakdown)') .option('--org ', 'target org (default: linked project\'s org)').option('--json') .action(guard((o) => billing(o))) -bill.command('upgrade ').description('Subscribe the org to a paid tier (pro|enterprise) via Stripe Checkout') +bill.command('upgrade ').description('Subscribe the org to a paid tier (pro|team) via Stripe Checkout') .option('--org ').option('--no-open', 'print the URL instead of opening a browser').option('--json') .action(guard((tier, o) => billingUpgrade(tier, o))) bill.command('portal').description('Open the Stripe Customer Portal (change plan / card / cancel)') diff --git a/test/billing.test.ts b/test/billing.test.ts index d41330c..729ead2 100644 --- a/test/billing.test.ts +++ b/test/billing.test.ts @@ -43,8 +43,66 @@ describe('billingLines', () => { expect(out).not.toContain('subscription:') }) - it('suspended: prints the warning', () => { - expect(billingLines({ ...base, billingStatus: 'suspended' }).join('\n')).toContain('org suspended') + // The advice is opposite per cause, so the wrong line is worse than none: waiting for the next + // cycle never settles an invoice, and there is no plan for a Team org to upgrade to. + it.each(['past_due', 'unpaid'])('suspended on a paid tier (%s): names the payment', (subscriptionStatus) => { + const out = billingLines({ ...base, billingStatus: 'suspended', subscriptionStatus }).join('\n') + expect(out).toContain('subscription payment did not go through') + expect(out).toContain('insta billing portal') + expect(out).not.toContain('resumes next cycle') + }) + + // The commands resolve the org independently, so a hint that drops --org acts on a different org + // than the one being read. Every hint, not just the portal one: the free-tier hint starts a + // Stripe Checkout, so dropping the flag there subscribes the wrong org. + it.each([ + ['paid', 'pro', 'past_due', 'insta billing portal --org org_123'], + ['ended', 'pro', 'canceled', 'insta billing upgrade pro --org org_123'], + ['free', 'free', null, 'insta billing upgrade pro --org org_123'], + ])('carries --org into the %s hint', (_label, tier, subscriptionStatus, expected) => { + const out = billingLines({ ...base, tier, subscriptionStatus, billingStatus: 'suspended' }, 'org_123').join('\n') + expect(out).toContain(expected) + }) + + // The resubscribe hint has to name the org's OWN tier. `insta billing upgrade pro` on a Team org + // resubscribes it onto the wrong plan, and enterprise has no self-serve command at all. + it.each([ + ['pro', 'insta billing upgrade pro'], + ['team', 'insta billing upgrade team'], + ])('suspended after a cancellation on %s: names that tier', (tier, expected) => { + const out = billingLines({ ...base, tier, billingStatus: 'suspended', subscriptionStatus: 'canceled' }).join('\n') + expect(out).toContain(expected) + }) + + it('suspended after a cancellation on enterprise: no self-serve command exists, so it says so', () => { + const out = billingLines({ ...base, tier: 'enterprise', billingStatus: 'suspended', subscriptionStatus: 'canceled' }).join('\n') + expect(out).toContain('contact support') + expect(out).not.toContain('insta billing upgrade') + }) + + // A cancelled subscription suspends the org and keeps its tier (platform#300), so "your + // subscription is current" is the one thing it is not — and there is no invoice to settle. + it('suspended after a cancellation: says the subscription ended, not that it is current', () => { + const out = billingLines({ ...base, billingStatus: 'suspended', subscriptionStatus: 'canceled' }).join('\n') + expect(out).toContain('the subscription ended; resubscribe') + expect(out).not.toContain('is current') + expect(out).not.toContain('did not go through') + }) + + it('suspended on free: still the wallet story, which a new cycle really does fix', () => { + const out = billingLines({ ...base, tier: 'free', billingStatus: 'suspended' }).join('\n') + expect(out).toContain('billing limit reached; resumes next cycle') + }) + + // Suspended while the subscription reads healthy: a recovery whose compute failed to restart. + // Both other lines are wrong here — there is no invoice to settle and no cycle to wait for. + it('suspended with a current subscription: neither of the other two stories', () => { + const out = billingLines({ ...base, billingStatus: 'suspended', subscriptionStatus: 'active' }).join('\n') + expect(out).toContain('contact support') + expect(out).not.toContain('did not go through') + expect(out).not.toContain('resumes next cycle') + // It must not assert the subscription is healthy: `incomplete` lands here too. + expect(out).not.toContain('is current') }) it('empty breakdowns: no breakdown headers', () => {