From 673e1245a85125092c83f46b3e3cd27d9785039a Mon Sep 17 00:00:00 2001 From: Lyu Date: Wed, 26 Aug 2026 22:03:38 -0700 Subject: [PATCH 1/7] billing: name the right cause when an org is suspended MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `insta billing` printed "billing limit reached; resumes next cycle (or `insta billing upgrade pro`)" for every suspended org. Once a paid org can be suspended for a failed subscription payment (InsForge/insta-platform#300), both halves of that are false for it: no cycle rollover settles an invoice, and a Team org has no plan to upgrade to. The tier separates the two causes by construction — the platform's suspendOrgCompute has one call path per cause. Co-Authored-By: Claude Opus 5 (1M context) --- src/commands/billing.ts | 10 +++++++++- test/billing.test.ts | 13 +++++++++++-- 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/src/commands/billing.ts b/src/commands/billing.ts index 237006d..11e7430 100644 --- a/src/commands/billing.ts +++ b/src/commands/billing.ts @@ -33,7 +33,15 @@ 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`)') + // Two causes, opposite advice. A free org spent its prepaid wallet: a new cycle grants a fresh + // one, so waiting works. A paid org's subscription lapsed, and no cycle rollover settles an + // invoice — telling that customer to wait, or to upgrade a plan they already have, is a dead + // end. The tier is what separates them (platform: suspendOrgCompute's two call paths). + lines.push( + s.tier === 'free' + ? '⚠ org suspended — billing limit reached; resumes next cycle (or `insta billing upgrade pro`)' + : '⚠ org suspended — subscription payment did not go through; settle it to restore service', + ) } if (s.byDimension?.length) { lines.push('by dimension:') diff --git a/test/billing.test.ts b/test/billing.test.ts index d41330c..ac99599 100644 --- a/test/billing.test.ts +++ b/test/billing.test.ts @@ -43,8 +43,17 @@ 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('suspended on a paid tier: names the payment, not the credit limit', () => { + const out = billingLines({ ...base, billingStatus: 'suspended' }).join('\n') + expect(out).toContain('subscription payment did not go through') + expect(out).not.toContain('resumes next cycle') + }) + + 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') }) it('empty breakdowns: no breakdown headers', () => { From 2ce47411534b9d47abbd0f5c56ee02f1ad2df64c Mon Sep 17 00:00:00 2001 From: Lyu Date: Wed, 26 Aug 2026 23:46:34 -0700 Subject: [PATCH 2/7] review: a suspension can outlive its cause, so name that state too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The platform's recovery path throws before flipping the status when an app fails to restart (InsForge/insta-platform#300), which leaves a third state: suspended while the subscription reads active. The tier alone called that a payment failure, contradicting the `subscription: active` line printed just above it and sending the customer to settle an invoice they had already paid — and the test fixture pinned exactly that wrong output, since `base` carries an active subscription. Tier still comes first, because legacy rows carry `unpaid` beside tier 'free' with no migration; the status then separates a lapse from a stalled recovery. The paid branch also names the command now, like the free one does. Co-Authored-By: Claude Opus 5 (1M context) --- .next/trace | 1 + .next/trace-build | 1 + src/commands/billing.ts | 16 +++++++++++----- test/billing.test.ts | 14 ++++++++++++-- 4 files changed, 25 insertions(+), 7 deletions(-) create mode 100644 .next/trace create mode 100644 .next/trace-build diff --git a/.next/trace b/.next/trace new file mode 100644 index 0000000..620b470 --- /dev/null +++ b/.next/trace @@ -0,0 +1 @@ +[{"name":"generate-buildid","duration":251,"timestamp":54594689986,"id":4,"parentId":1,"tags":{},"startTime":1787619315178,"traceId":"f85edf534bc9dd8d"},{"name":"load-custom-routes","duration":321,"timestamp":54594690349,"id":5,"parentId":1,"tags":{},"startTime":1787619315179,"traceId":"f85edf534bc9dd8d"},{"name":"create-dist-dir","duration":300,"timestamp":54594690686,"id":6,"parentId":1,"tags":{},"startTime":1787619315179,"traceId":"f85edf534bc9dd8d"},{"name":"clean","duration":269,"timestamp":54594691657,"id":7,"parentId":1,"tags":{},"startTime":1787619315180,"traceId":"f85edf534bc9dd8d"},{"name":"next-build","duration":1056005,"timestamp":54593636066,"id":1,"tags":{"buildMode":"default","version":"16.3.2","bundler":"turbopack","failed":true},"startTime":1787619314124,"traceId":"f85edf534bc9dd8d"}] diff --git a/.next/trace-build b/.next/trace-build new file mode 100644 index 0000000..2ba2026 --- /dev/null +++ b/.next/trace-build @@ -0,0 +1 @@ +[{"name":"next-build","duration":1056005,"timestamp":54593636066,"id":1,"tags":{"buildMode":"default","version":"16.3.2","bundler":"turbopack","failed":true},"startTime":1787619314124,"traceId":"f85edf534bc9dd8d"}] diff --git a/src/commands/billing.ts b/src/commands/billing.ts index 11e7430..3eea833 100644 --- a/src/commands/billing.ts +++ b/src/commands/billing.ts @@ -33,14 +33,20 @@ export function billingLines(s: BillingOverview): string[] { ] if (s.subscriptionStatus) lines.push(`subscription: ${s.subscriptionStatus}`) if (s.billingStatus === 'suspended') { - // Two causes, opposite advice. A free org spent its prepaid wallet: a new cycle grants a fresh - // one, so waiting works. A paid org's subscription lapsed, and no cycle rollover settles an - // invoice — telling that customer to wait, or to upgrade a plan they already have, is a dead - // end. The tier is what separates them (platform: suspendOrgCompute's two call paths). + // Three states, and the advice for each 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.) Within a paid org, the status + // then separates a lapse — where settling the invoice is the fix — from a suspension that + // outlived its cause, which is what a recovery whose compute failed to restart looks like: + // telling that customer to pay again would send them to re-settle a paid invoice. + const lapsed = s.subscriptionStatus === 'past_due' || s.subscriptionStatus === 'unpaid' lines.push( s.tier === 'free' ? '⚠ org suspended — billing limit reached; resumes next cycle (or `insta billing upgrade pro`)' - : '⚠ org suspended — subscription payment did not go through; settle it to restore service', + : lapsed + ? '⚠ org suspended — subscription payment did not go through; settle it in `insta billing portal`' + : '⚠ org suspended — the subscription is current, so this needs a hand; contact support', ) } if (s.byDimension?.length) { diff --git a/test/billing.test.ts b/test/billing.test.ts index ac99599..df6cff2 100644 --- a/test/billing.test.ts +++ b/test/billing.test.ts @@ -45,9 +45,10 @@ describe('billingLines', () => { // 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('suspended on a paid tier: names the payment, not the credit limit', () => { - const out = billingLines({ ...base, billingStatus: 'suspended' }).join('\n') + 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') }) @@ -56,6 +57,15 @@ describe('billingLines', () => { 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('empty breakdowns: no breakdown headers', () => { const out = billingLines({ ...base, byDimension: [], byProject: [] }).join('\n') expect(out).not.toContain('by dimension:') From 0e4dd029e5cd92d9de20f4d6079970729f1b05d0 Mon Sep 17 00:00:00 2001 From: Lyu Date: Wed, 26 Aug 2026 23:50:56 -0700 Subject: [PATCH 3/7] review: drop the stray .next artifacts, and keep --org in the portal hint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two turbopack trace files from a failed `next build` were swept into this branch by `git add -A` — twice, because the first fix only untracked them and the repo has no `.next/` ignore. Removed, deleted from the tree, and ignored so the next build here cannot repeat it. The portal hint also dropped the caller's `--org`: `billing` and `billing portal` resolve the target independently, so reading org A's overview and following the hint verbatim opens org B's portal. Co-Authored-By: Claude Opus 5 (1M context) --- .gitignore | 4 ++++ .next/trace | 1 - .next/trace-build | 1 - src/commands/billing.ts | 9 ++++++--- test/billing.test.ts | 7 +++++++ 5 files changed, 17 insertions(+), 5 deletions(-) delete mode 100644 .next/trace delete mode 100644 .next/trace-build 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/.next/trace b/.next/trace deleted file mode 100644 index 620b470..0000000 --- a/.next/trace +++ /dev/null @@ -1 +0,0 @@ -[{"name":"generate-buildid","duration":251,"timestamp":54594689986,"id":4,"parentId":1,"tags":{},"startTime":1787619315178,"traceId":"f85edf534bc9dd8d"},{"name":"load-custom-routes","duration":321,"timestamp":54594690349,"id":5,"parentId":1,"tags":{},"startTime":1787619315179,"traceId":"f85edf534bc9dd8d"},{"name":"create-dist-dir","duration":300,"timestamp":54594690686,"id":6,"parentId":1,"tags":{},"startTime":1787619315179,"traceId":"f85edf534bc9dd8d"},{"name":"clean","duration":269,"timestamp":54594691657,"id":7,"parentId":1,"tags":{},"startTime":1787619315180,"traceId":"f85edf534bc9dd8d"},{"name":"next-build","duration":1056005,"timestamp":54593636066,"id":1,"tags":{"buildMode":"default","version":"16.3.2","bundler":"turbopack","failed":true},"startTime":1787619314124,"traceId":"f85edf534bc9dd8d"}] diff --git a/.next/trace-build b/.next/trace-build deleted file mode 100644 index 2ba2026..0000000 --- a/.next/trace-build +++ /dev/null @@ -1 +0,0 @@ -[{"name":"next-build","duration":1056005,"timestamp":54593636066,"id":1,"tags":{"buildMode":"default","version":"16.3.2","bundler":"turbopack","failed":true},"startTime":1787619314124,"traceId":"f85edf534bc9dd8d"}] diff --git a/src/commands/billing.ts b/src/commands/billing.ts index 3eea833..107542a 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}`, @@ -45,7 +48,7 @@ export function billingLines(s: BillingOverview): string[] { s.tier === 'free' ? '⚠ org suspended — billing limit reached; resumes next cycle (or `insta billing upgrade pro`)' : lapsed - ? '⚠ org suspended — subscription payment did not go through; settle it in `insta billing portal`' + ? `⚠ org suspended — subscription payment did not go through; settle it in \`insta billing portal${org ? ` --org ${org}` : ''}\`` : '⚠ org suspended — the subscription is current, so this needs a hand; contact support', ) } @@ -66,7 +69,7 @@ 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. diff --git a/test/billing.test.ts b/test/billing.test.ts index df6cff2..9999f5b 100644 --- a/test/billing.test.ts +++ b/test/billing.test.ts @@ -52,6 +52,13 @@ describe('billingLines', () => { expect(out).not.toContain('resumes next cycle') }) + // The two commands resolve the org independently, so a hint that drops --org sends someone + // reading one org's overview to another org's portal. + it('carries --org into the portal hint when the caller targeted an org', () => { + const out = billingLines({ ...base, billingStatus: 'suspended', subscriptionStatus: 'past_due' }, 'org_123').join('\n') + expect(out).toContain('insta billing portal --org org_123') + }) + 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') From 0d5b17c7c70de4987d3eb091108c6be375bf3894 Mon Sep 17 00:00:00 2001 From: Lyu Date: Thu, 27 Aug 2026 11:59:06 -0700 Subject: [PATCH 4/7] billing: carry --org into every hint, and name the cancelled state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The last commit threaded --org into the portal hint and left the free-tier hint one line above without it — and that one starts a Stripe Checkout, so following it verbatim from another directory subscribes the wrong org. Fixed the class: every command in these hints carries the flag. A cancelled subscription now suspends the org and keeps its tier (InsForge/insta-platform#300), so it arrives as a paid org that is neither lapsed nor current. It has its own line: resubscribe. Co-Authored-By: Claude Opus 5 (1M context) --- src/commands/billing.ts | 13 ++++++++++--- test/billing.test.ts | 24 +++++++++++++++++++----- 2 files changed, 29 insertions(+), 8 deletions(-) diff --git a/src/commands/billing.ts b/src/commands/billing.ts index 107542a..823b484 100644 --- a/src/commands/billing.ts +++ b/src/commands/billing.ts @@ -43,13 +43,20 @@ export function billingLines(s: BillingOverview, org?: string): string[] { // then separates a lapse — where settling the invoice is the fix — from a suspension that // outlived its cause, which is what a recovery whose compute failed to restart looks like: // telling that customer to pay again would send them to re-settle a paid invoice. + // EVERY command in these hints 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 one of them opens a Stripe Checkout. + 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`)' + ? `⚠ 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${org ? ` --org ${org}` : ''}\`` - : '⚠ org suspended — the subscription is current, so this needs a hand; contact support', + ? `⚠ org suspended — subscription payment did not go through; settle it in \`insta billing portal${flag}\`` + : ended + ? `⚠ org suspended — the subscription ended; resubscribe with \`insta billing upgrade pro${flag}\`` + : '⚠ org suspended — the subscription is current, so this needs a hand; contact support', ) } if (s.byDimension?.length) { diff --git a/test/billing.test.ts b/test/billing.test.ts index 9999f5b..57bfca9 100644 --- a/test/billing.test.ts +++ b/test/billing.test.ts @@ -52,11 +52,25 @@ describe('billingLines', () => { expect(out).not.toContain('resumes next cycle') }) - // The two commands resolve the org independently, so a hint that drops --org sends someone - // reading one org's overview to another org's portal. - it('carries --org into the portal hint when the caller targeted an org', () => { - const out = billingLines({ ...base, billingStatus: 'suspended', subscriptionStatus: 'past_due' }, 'org_123').join('\n') - expect(out).toContain('insta billing portal --org org_123') + // 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) + }) + + // 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', () => { From b7eb3a194d2aaaf61fd4b6ea60422842f5910b75 Mon Sep 17 00:00:00 2001 From: Lyu Date: Thu, 27 Aug 2026 12:53:14 -0700 Subject: [PATCH 5/7] billing: name the org's own tier when telling it to resubscribe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The resubscribe hint said `upgrade pro` whatever the org was on, so a Team org was told to resubscribe onto the wrong plan. It names the org's tier now, and enterprise — which has no self-serve checkout at all — is told to contact support instead of being offered a command that would move it off a negotiated plan. That exposed the command it points at: `billing upgrade` validated pro|enterprise while the platform's checkout route accepts pro|team. Wrong both ways — it refused a real tier and offered one the server 400s on. Mirrored in the skills reference per AGENTS.md rule 4. Co-Authored-By: Claude Opus 5 (1M context) --- src/commands/billing.ts | 31 +++++++++++++++++++++---------- test/billing.test.ts | 16 ++++++++++++++++ 2 files changed, 37 insertions(+), 10 deletions(-) diff --git a/src/commands/billing.ts b/src/commands/billing.ts index 823b484..4587f3f 100644 --- a/src/commands/billing.ts +++ b/src/commands/billing.ts @@ -36,16 +36,17 @@ export function billingLines(s: BillingOverview, org?: string): string[] { ] if (s.subscriptionStatus) lines.push(`subscription: ${s.subscriptionStatus}`) if (s.billingStatus === 'suspended') { - // Three states, and the advice for each is a dead end for the others. Tier first: only a free + // Four states, and each one's advice is a dead end for the other three. 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.) Within a paid org, the status - // then separates a lapse — where settling the invoice is the fix — from a suspension that - // outlived its cause, which is what a recovery whose compute failed to restart looks like: - // telling that customer to pay again would send them to re-settle a paid invoice. - // EVERY command in these hints 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 one of them opens a Stripe Checkout. + // `unpaid` beside tier 'free' and survive with no migration.) Then the status splits the paid + // branch: 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. + // + // 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' @@ -55,7 +56,13 @@ export function billingLines(s: BillingOverview, org?: string): string[] { : lapsed ? `⚠ org suspended — subscription payment did not go through; settle it in \`insta billing portal${flag}\`` : ended - ? `⚠ org suspended — the subscription ended; resubscribe with \`insta billing upgrade pro${flag}\`` + ? 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}\`` : '⚠ org suspended — the subscription is current, so this needs a hand; contact support', ) } @@ -81,7 +88,11 @@ export async function billing(opts: OrgOpt & { json?: boolean }): Promise // 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/test/billing.test.ts b/test/billing.test.ts index 57bfca9..d0da1cb 100644 --- a/test/billing.test.ts +++ b/test/billing.test.ts @@ -64,6 +64,22 @@ describe('billingLines', () => { 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', () => { From a84514ec515f6b16f4702482c96c5d9843ab5eea Mon Sep 17 00:00:00 2001 From: Lyu Date: Thu, 27 Aug 2026 13:02:17 -0700 Subject: [PATCH 6/7] docs: four causes, five messages The enterprise split made a fifth branch and the comment above still said four alternatives. Co-Authored-By: Claude Opus 5 (1M context) --- src/commands/billing.ts | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/commands/billing.ts b/src/commands/billing.ts index 4587f3f..7cf5936 100644 --- a/src/commands/billing.ts +++ b/src/commands/billing.ts @@ -36,13 +36,14 @@ export function billingLines(s: BillingOverview, org?: string): string[] { ] if (s.subscriptionStatus) lines.push(`subscription: ${s.subscriptionStatus}`) if (s.billingStatus === 'suspended') { - // Four states, and each one's advice is a dead end for the other three. Tier first: only a free - // org can spend a prepaid wallet, and waiting for the next cycle genuinely fixes that one. + // 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: 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. + // 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 From 0686bd9a9c114fe7a625c57be0ea4dcf091fc76c Mon Sep 17 00:00:00 2001 From: Lyu Date: Thu, 27 Aug 2026 15:38:15 -0700 Subject: [PATCH 7/7] billing: --help says pro|team too, and the last branch stops asserting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The validation moved to pro|team and the command description did not, so `--help` advertised the value the command now refuses and never mentioned the one the resubscribe hint hands a Team org. The fallback message also claimed "the subscription is current". `incomplete` reaches it, and that is neither current nor failed — all the branch knows is that there is no failed payment on file. Co-Authored-By: Claude Opus 5 (1M context) --- src/commands/billing.ts | 5 ++++- src/index.ts | 2 +- test/billing.test.ts | 2 ++ 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/src/commands/billing.ts b/src/commands/billing.ts index 7cf5936..bbbe86a 100644 --- a/src/commands/billing.ts +++ b/src/commands/billing.ts @@ -64,7 +64,10 @@ export function billingLines(s: BillingOverview, org?: string): string[] { // 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}\`` - : '⚠ org suspended — the subscription is current, so this needs a hand; contact support', + // 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) { 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 d0da1cb..729ead2 100644 --- a/test/billing.test.ts +++ b/test/billing.test.ts @@ -101,6 +101,8 @@ describe('billingLines', () => { 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', () => {