Skip to content
Merged
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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/
47 changes: 43 additions & 4 deletions src/commands/billing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`,
Expand All @@ -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:')
Expand All @@ -52,12 +87,16 @@ export async function billing(opts: OrgOpt & { json?: boolean }): Promise<void>
const orgId = await resolveOrgId(opts)
const s = await api.request<BillingOverview>('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 <tier> — 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<void> {
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 })
Expand Down
2 changes: 1 addition & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <id>', 'target org (default: linked project\'s org)').option('--json')
.action(guard((o) => billing(o)))
bill.command('upgrade <tier>').description('Subscribe the org to a paid tier (pro|enterprise) via Stripe Checkout')
bill.command('upgrade <tier>').description('Subscribe the org to a paid tier (pro|team) via Stripe Checkout')
.option('--org <id>').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)')
Expand Down
62 changes: 60 additions & 2 deletions test/billing.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
Loading