Skip to content

billing: name the right cause when an org is suspended - #153

Merged
Fermionic-Lyu merged 7 commits into
mainfrom
fix/suspended-paid-org-message
Aug 27, 2026
Merged

billing: name the right cause when an org is suspended#153
Fermionic-Lyu merged 7 commits into
mainfrom
fix/suspended-paid-org-message

Conversation

@Fermionic-Lyu

@Fermionic-Lyu Fermionic-Lyu commented Aug 27, 2026

Copy link
Copy Markdown
Member

Companion to InsForge/insta-platform#300. A reviewer there asked for this to land with that change rather than after, and they're right — the platform PR is what makes the current line false.

What

insta billing prints one warning for every suspended org:

⚠  org suspended — billing limit reached; resumes next cycle (or `insta billing upgrade pro`)

That is the free-tier story. Once a paid org can be suspended because its subscription went past_due/unpaid, both halves are wrong for that customer: rollover explicitly does not resume them (the platform PR pins that in a test), and a Team org has no plan above it to upgrade to. So the person whose card failed is told to wait for something that will never happen.

It now branches on the tier, which separates the two causes by construction — the platform's suspendOrgCompute has exactly one call path per cause (accrue's free branch for a spent wallet, StripeService.syncSubscription for a lapsed subscription).

Why not subscriptionStatus, which is right there in the payload

Rows written before the platform change carry unpaid alongside tier: 'free' with the org still running, and survive with no migration. When such an org's wallet later empties, a status-derived rule would call that wallet suspension a payment failure. Same reasoning as InsForge/insta-frontend#303, where the console landed on the tier for the same reason.

Tests

The existing test asserted only toContain('org suspended'), which both messages satisfy — it could not have caught this. Replaced with one test per cause, each asserting the other message is absent. Mutation-checked: collapsing the branch back to a single message fails the paid case.

628 tests pass.


Summary by cubic

insta billing now names the real cause when an org is suspended, instead of telling every org the free-tier story ("billing limit reached; resumes next cycle"). That line is false for paid orgs, where cycle rollover never settles a failed invoice and a Team org has no plan above it to upgrade to.

  • Free orgs keep the wallet-exhaustion message; the tier decides free vs paid because legacy rows carry unpaid beside tier free with no migration.
  • Paid orgs see a message per cause: lapsed means settle it in insta billing portal, ended means resubscribe (enterprise contacts support), and no failed payment on file means contact support.
  • Every hint echoes the caller's --org, since billing and the suggested commands resolve the target independently.
  • billing upgrade now accepts pro|team, matching the checkout route and the --help text; it previously rejected team and offered enterprise, which the server rejects.
  • .next/ is gitignored so stray turbopack output can't be swept into commits again.
  • Tests assert each message per cause and that the other messages are absent.

Written for commit 0686bd9. Summary will update on new commits.

Review in cubic

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No issues found across 4 files

Re-trigger cubic

`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) <noreply@anthropic.com>
@Fermionic-Lyu
Fermionic-Lyu force-pushed the fix/suspended-paid-org-message branch from 2889eb4 to 673e124 Compare August 27, 2026 06:35

@agent-zhang-beihai agent-zhang-beihai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed by Wang Miao

This splits the insta billing suspension warning in two: a free org still hears "billing limit reached; resumes next cycle", while a paid org is told its subscription payment failed. I checked the discriminator against the platform change it anticipates (InsForge/insta-platform#300), and keying on tier is right — past_due/unpaid suspend the org and deliberately keep the paid tier, and the free wallet path is the only other way billing_status becomes suspended. Two minor gaps, neither blocking, so this is an approve.

Suspended with a healthy subscription falls into the "payment failed" branch

minor · defect · edge-cases · src/commands/billing.ts:41

The two causes are not exhaustive once the platform side lands. In InsForge/insta-platform#300, resumeOrgCompute now throws before setOrgStatus(orgId, 'active') if any Fly app fails to start, so: a pro org goes past_due → suspended → the customer pays → customer.subscription.updated arrives active, syncSubscription writes stripe_subscription_status = 'active', then the resume fails on one app and leaves billing_status = 'suspended'. insta billing then prints subscription: active at line 34 and subscription payment did not go through; settle it to restore service at line 43 — self-contradictory, and it sends someone to the Portal to re-settle an invoice that is already paid. subscriptionStatus is already in the payload and already printed; gating the payment wording on it being past_due/unpaid/incomplete (and falling back to a neutral "org suspended — contact support" otherwise) distinguishes the third state that tier alone cannot. The new test fixture at test/billing.test.ts:49 is exactly that state — billingStatus: 'suspended' over base's subscriptionStatus: 'active' — and pins the payment message as the expected output for it.

Evidence

read-the-codesrc/commands/billing.ts:34-44, test/billing.test.ts:4-7,48-57; insta-platform at afbef1e: src/billing/service.ts:40-46,132-141,155-183, src/billing/stripe.ts:295-331,376-425, src/billing/repos.ts:211. Partly unverifiable-here: InsForge/insta-platform#300 is still open, so the resume-throws behaviour and the tier-preserving unpaid branch are read from its diff (suspendOrgCompute, resumeOrgCompute, syncSubscription), not from merged code.

The paid branch tells the user to settle the invoice but names no command

minor · judgement · conventions · src/commands/billing.ts:43

The free branch it replaces ends in the exact command to run; the paid branch ends in "settle it", leaving the reader to discover the mechanism. That mechanism is thirty lines below in the same file — billingPortal at src/commands/billing.ts:76, registered as insta billing portal ("change plan / card / cancel"), and its route is not behind assertOrgActive, so it still works while the org is suspended. … did not go through; settle it in \insta billing portal`` costs four words and matches how this CLI ends its other dead-ends.

Evidence

read-the-codesrc/commands/billing.ts:42-43,76-83, src/index.ts:327-329; convention cited at src/api.ts:128, src/resolve-project.ts:20,29, src/commands/auth.ts:161, src/commands/branch.ts:29. Portal route not gated: insta-platform src/server.ts:3378-3396 (membership check only, no assertOrgActive).

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) <noreply@anthropic.com>
@Fermionic-Lyu

Copy link
Copy Markdown
Member Author

Both taken — thank you, the first one is a defect I introduced in the platform PR and then encoded in this PR's test.

✅ Suspended with a healthy subscription falls into the "payment failed" branch

Correct, and the sharpest part of the finding is that my own fixture pinned the wrong output: base carries subscriptionStatus: 'active', so the test I wrote to prove the new message asserted it for exactly the state where it's false. That's the third time this PR chain has produced a test that agreed with the bug.

The state is real and I made it: resumeOrgCompute in platform#300 now throws before setOrgStatus(orgId, 'active') when an app fails to restart — deliberately, so the recovery is retried rather than declared done — which leaves billing_status = 'suspended' beside stripe_subscription_status = 'active'. Printing "settle it" there contradicts the subscription: active line two rows up and sends someone to re-pay a paid invoice.

Now: tier first, then status. Tier still leads, for the legacy-row reason you verified in round 2 — unpaid beside tier free on rows that predate the change, with no migration. Status then splits the paid branch into a lapse (past_due/unpaid → settle it) and a suspension that outlived its cause (→ contact support). Neither field alone is sufficient, which I'd got half-right in both directions.

The fixture is fixed too: the paid tests now set a lapsed status explicitly, and there's a test for the third state asserting the other two messages are absent. Mutation-checked by collapsing back to two branches.

✅ The paid branch names no command

Taken — insta billing portal, and thanks for confirming its route isn't behind assertOrgActive, which is what makes it a usable instruction for a suspended org. Matches how the free branch and the rest of this CLI end their dead-ends.


The same defect was in the console, so InsForge/insta-frontend#303 now carries the identical three-way split (credit / payment / stalled) with the same tier-then-status ordering.

630 tests pass here, 821 there.

@agent-zhang-beihai agent-zhang-beihai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed by Wang Miao

Round 2. Both findings from the last round are genuinely fixed: the paid branch now names insta billing portal, and the third state — suspended while the subscription reads healthy — has its own line and its own test, so the fixture no longer pins the contradictory output. The billing logic reads correctly to me now; what blocks this is that the new commit also carries two Next.js build artifacts into the repo, which a squash makes permanent.

The commit adds .next/ build output to the repository

important · defect · conventions · .next/trace:1

2ce4741 tracks .next/trace and .next/trace-build — turbopack trace output from a failed next build ("version":"16.3.2","failed":true), in a repo with no next dependency and nothing on main under .next/. Neither the commit message nor the PR mentions them. Squash-merging puts them on main for good, and since .gitignore covers node_modules/, dist/ and coverage/ but not .next/, the next person who runs a build here gets a dirty tree and the same accident is available again. They do not reach npm — package.json's files is ["dist/**/*.js"] — so this is repo hygiene, not a shipped artifact. Drop both files from the branch; adding .next/ to .gitignore alongside the other build outputs would stop the recurrence.

Evidence

read-the-codegit show --stat 2ce4741, git ls-files .next, .gitignore:1-10, package.json (files, no next dependency); git ls-tree -r origin/main has no .next entries.

"The subscription is current" is asserted, not checked — incomplete lands there

minor · defect · edge-cases · src/commands/billing.ts:49

lapsed names two statuses, and everything else on a paid tier gets the stalled-recovery line, which states the subscription is current. incomplete is not: syncSubscription's last branch keeps/sets the paid tier for active | trialing | past_due | incomplete and resumes only on active/trialing, so a free org that exhausted its wallet, got suspended, and then upgraded through Checkout reads tier: pro, billing_status: suspended, subscriptionStatus: incomplete between customer.subscription.created and the checkout.session.completed that forces the status to active. In that window insta billing prints subscription: incomplete at line 34 and "the subscription is current, so this needs a hand; contact support" at line 49 — the same self-contradiction this commit removed for active, sending someone whose payment simply has not settled to support instead of to finishing it. Treating incomplete as not-current (either fold it into lapsed, or gate the third line on active/trialing and let anything else say the payment has not settled) closes it.

Evidence

read-the-codesrc/commands/billing.ts:34,43-50; insta-platform at afbef1e: src/billing/stripe.ts:261-283 (session handler forces active + resumes), src/billing/stripe.ts:405-425 (incomplete keeps the paid tier, no resume), src/billing/stripe.ts:160-216 (promoteSubscription — checked and ruled out as a second path: trialDays defaults to 30 and is minimum: 1 at src/server.ts:4107,4119, so a comp subscription is always trialing and resumes). unverifiable-here: how long that window stays open depends on Stripe's delivery order for customer.subscription.created versus checkout.session.completed, which I cannot confirm from either repository.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 4 files (changes from recent commits).

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

Comment thread src/commands/billing.ts Outdated
…hint

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) <noreply@anthropic.com>
@Fermionic-Lyu

Copy link
Copy Markdown
Member Author

Both fixed.

✅ The commit adds .next/ build output to the repository

My mistake, and worse than it looks: this was the second time. The first push carried the same two files, I spotted them and removed them — but with git rm --cached, which untracks without deleting, and the repo has no .next/ ignore. So the next git add -A picked them straight back up and I didn't check.

Fixed properly this round: removed from the branch, deleted from the tree, and .gitignore now covers .next/ with a note saying why, so the accident isn't available again. The PR is now three files.

I've also audited the working trees of the two companion repos for the same class of stray — both clean.

✅ (cubic) The portal hint drops the caller's --org

Real, and a good catch on an instruction I was adding: billing and billing portal resolve their target independently via resolveOrgId, so someone reading org A's overview and following the hint verbatim lands in org B's portal. billingLines now takes the caller's --org and echoes it into the hint when it was given — short form preserved for the common case, since appending a resolved id unconditionally would put a uuid in every suspended org's warning.

631 tests pass.

@agent-zhang-beihai agent-zhang-beihai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed by Wang Miao

The stray .next artifacts are gone from the tree and .next/ is now ignored, so that one is closed. The billing logic reads correctly for the three states it names, and threading --org into the portal hint is the right call. But the same commit leaves the sibling hint one line above without --org, and that one starts a Stripe Checkout — so I would not merge it as it stands.

The free-tier hint still drops --org, and that one takes payment

important · defect · correctness · src/commands/billing.ts:49

This commit fixed exactly this bug on line 51 and left it on line 49. Trigger: insta billing --org org_A on a suspended free org prints "(or insta billing upgrade pro)"; the user runs it verbatim from a directory linked to a project in org B, resolveOrgId falls through to requireProject(), and Checkout opens for org B. With no linked project it is worse, not safer — requireProject auto-resolves to orgs[0]'s first project and persists it (src/api.ts:105-118) rather than erroring. Nothing downstream names the target: billingUpgrade prints "Subscribe to pro — complete checkout in your browser:" with no org in it (src/commands/billing.ts:82), so the first sign of the mistake is a subscription on the wrong org. The string predates this PR, but the diff rewrites this ternary and the commit that fixed its twin is the moment to give it the same ${org ? \ --org ${org}` : ''}` treatment.

Evidence

read-the-codesrc/commands/billing.ts:25-53,66-83 (resolveOrgId, billingLines, billingUpgrade, presentUrl), src/api.ts:105-125 (requireProjectautoResolveProject), src/index.ts:324-326 (billing upgrade accepts --org). Callers of the changed billingLines signature checked: src/commands/billing.ts:72 and test/billing.test.ts are the only ones (src/commands/services.ts:136 is a prose mention).

"The subscription is current" is asserted, not checked — incomplete lands there

minor · defect · edge-cases · src/commands/billing.ts:52

Unchanged from the last round. lapsed names two statuses and everything else on a paid tier gets the stalled-recovery line, which states the subscription is current. incomplete is not: syncSubscription keeps the paid tier for active | trialing | past_due | incomplete and resumes only on active/trialing, so a free org that exhausted its wallet, got suspended, then upgraded through Checkout reads tier: pro, billing_status: suspended, subscriptionStatus: incomplete between customer.subscription.created and the checkout.session.completed that forces the status to active. In that window line 37 prints subscription: incomplete and line 52 says the subscription is current — the same self-contradiction round 2 removed for active, sending someone whose payment simply has not settled to support instead of to finishing it. Gate the third line on active/trialing (or fold incomplete into lapsed) and it closes.

Evidence

read-the-codesrc/commands/billing.ts:37,46-53; insta-platform at afbef1e: src/billing/stripe.ts:261-283, 405-425, and 160-216 with src/server.ts:4107,4119 (promoteSubscription ruled out as a second path — trialDays is minimum: 1, default 30, so a comp subscription is always trialing and resumes). unverifiable-here: how long that window stays open depends on Stripe's delivery order for customer.subscription.created versus checkout.session.completed, which I cannot confirm from either repository.

@agent-zhang-beihai agent-zhang-beihai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed by Yang Dong

This correctly distinguishes wallet exhaustion, failed subscription payments, and stale suspensions after recovery, while preserving an explicit --org in the portal hint. The billing behavior is sound and I would approve, with one non-blocking scope issue.

The repository-wide .next ignore is unrelated to the billing fix

minor · judgement · simplicity · .gitignore:19

Ignoring build artifacts from a framework this repository does not use changes repository-wide behavior without contributing to the billing correction. Remove these three lines from this PR and, if the protection is still wanted, land them as a separate housekeeping change.

Evidence

read-the-code.gitignore:19-21, src/commands/billing.ts:38-53

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) <noreply@anthropic.com>

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 2 files (changes from recent commits).

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

Comment thread src/commands/billing.ts Outdated
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) <noreply@anthropic.com>
Fermionic-Lyu added a commit to InsForge/insta-skills that referenced this pull request Aug 27, 2026
The platform's checkout route accepts pro|team (`POST /orgs/:orgId/billing/checkout`).
The CLI's own validation said pro|enterprise, which refused a real self-serve tier
and offered one the server 400s on; that is fixed in InsForge/insta-cli#153, and
this is the agent-facing surface doc it has to be mirrored in.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 2 files (changes from recent commits).

You’re at about 90% of the monthly reviewed-line limit. You may want to disable incremental reviews to conserve quota. Reviews will continue until that limit is exceeded. If you need help avoiding interruptions, please contact contact@cubic.dev.

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

Comment thread src/commands/billing.ts Outdated
The enterprise split made a fifth branch and the comment above still said four
alternatives.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@agent-zhang-beihai agent-zhang-beihai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed by Wang Miao

insta billing's suspension warning now splits five ways, and the same commits fixed the --org leak in every hint and corrected billing upgrade's tier validation to pro|team — that last catch is right, the platform's checkout route accepts exactly those two and 400s on enterprise, and the skills reference is already mirrored. Two things stop me approving: the command's own --help still advertises the value it now rejects, and the new "subscription ended" branch describes a state the platform cannot produce.

insta billing upgrade --help still says pro|enterprise, which the command now refuses

important · defect · regression · src/index.ts:324

billingUpgrade changed to pro|team in this PR (src/commands/billing.ts:96) and the description above it did not. So insta billing upgrade --help prints "Subscribe the org to a paid tier (pro|enterprise) via Stripe Checkout", and running the value it names dies with "tier must be pro|team", while team — the one the new resubscribe hint hands a Team org — appears nowhere in the help. The agent-facing surface is already correct (insta/cli-reference.md in insta-skills reads insta billing upgrade <pro|team>), which leaves this line as the only place still describing the old contract.

Evidence

read-the-codesrc/index.ts:324-326, src/commands/billing.ts:90-100; insta-platform at afbef1e: src/server.ts:3355-3373 (body enum ['pro','team'], handler 400s otherwise), src/billing/stripe.ts:24,63-66,248-253 (Tier = 'pro' | 'team'; no enterprise price exists); insta-skills insta/cli-reference.md:59 fetched at review time.

The "subscription ended" branch cannot be reached — a cancellation downgrades the org to free

important · judgement · simplicity · src/commands/billing.ts:59

ended requires a paid tier beside canceled/incomplete_expired, and the platform never writes that pair. Every write to stripe_subscription_status goes through setOrgSubscription, and both call sites that set a terminal status set tier: 'free' in the same statement — deliberately, and unchanged by InsForge/insta-platform#300, whose own rationale is that suspending on a cancellation would brick a voluntary cancel and a never-settled upgrade. The comment at line 46 and the test at test/billing.test.ts:84 assert the opposite ("a cancelled subscription suspends the org and keeps its tier (platform#300)"), so three of the five messages and four of the new tests encode a contract that does not exist. What actually happens after a cancellation is tier: 'free' plus, if the wallet is spent, still suspended — which lands on the free branch and is already correct there, because ensureCycle resumes a suspended free org at rollover. Deleting ended and its enterprise split costs nothing and removes the wrong mental model.

Evidence

read-the-codesrc/commands/billing.ts:39-67, test/billing.test.ts:66-90; insta-platform at afbef1e: src/billing/repos.ts:255-270 (sole writer) with all four call sites src/billing/stripe.ts:282,310,395,410-415, plus src/billing/service.ts:132-141 (free rollover resume) and the terminal-branch diff of InsForge/insta-platform#300. One caveat: priceToTier knows only pro/team, so an enterprise tier is set outside both repositories — a hand-edited row could in principle hold a stale canceled, but no code path in either repo produces it.

"The subscription is current" is asserted, not checked — incomplete lands there

minor · defect · edge-cases · src/commands/billing.ts:67

Unchanged from the last two rounds, and the new ended arm did not touch it. incomplete is the status that genuinely does co-occur with a paid suspended org — syncSubscription keeps the paid tier for active | trialing | past_due | incomplete and resumes only on active/trialing — so a free org that exhausted its wallet, got suspended, then upgraded through Checkout reads tier: pro, billing_status: suspended, subscriptionStatus: incomplete until checkout.session.completed forces the status to active. In that window line 37 prints subscription: incomplete and line 67 says the subscription is current, sending someone whose payment simply has not settled to support instead of to finishing it. Gate the last arm on active/trialing rather than using it as the catch-all.

Evidence

read-the-codesrc/commands/billing.ts:37,52-67; insta-platform at afbef1e: src/billing/stripe.ts:261-283,405-418, and 160-216 with src/server.ts:4107,4119 (promoteSubscription ruled out — trialDays is minimum: 1, default 30, so a comp subscription is always trialing and resumes). unverifiable-here: how long the window stays open depends on Stripe's delivery order for customer.subscription.created versus checkout.session.completed, which neither repository settles.

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) <noreply@anthropic.com>
@Fermionic-Lyu

Copy link
Copy Markdown
Member Author

Two taken, one rejected on evidence.

--help still said pro|enterprise

Fixed. I changed the validation and left the description one file over — the same instance-not-class mistake this PR already made once with --org, where I threaded the flag into the portal hint and left it out of the free-tier hint. Corrected in src/index.ts.

✅ "The subscription is current" was asserted, not checked

Right. incomplete reaches that branch, and it is neither current nor failed. It now says what the branch actually knows — no failed payment on file — and the test asserts it does not claim currency.

❌ The "subscription ended" branch cannot be reached

This one is reading the wrong commit. The evidence block cites insta-platform at afbef1e, which is that repo's main. The change this PR is a companion to is on feat/suspend-on-nonpayment, head 7da0346, where the terminal branch is:

const toFree = !(await repo.ownerHasOtherFreeOrg(orgId))
await repo.setOrgSubscription(orgId, { tier: toFree ? 'free' : undefined, ... })
if (!toFree && !deferCompute && current?.tier !== 'free') await suspendOrgCompute(...)

So a cancellation sets tier: 'free' only when the owner has no other free org. Otherwise the tier is kept and the org is suspended — which is exactly the paid+canceled pair the finding says the platform never writes.

You are also quoting a rationale I no longer hold: "suspending on a cancellation would brick a voluntary cancel" was my argument in an earlier round of #300, and the product owner overruled it. The reason is on that PR: paid org creation bypasses countFreeOwnedBy, so downgrading a cancelled org to free sold a permanent second free org — with its own $5/month credit — for one month of Pro, repeatable. Asking the one-free-org rule at the moment the tier changes closes that without stranding the org someone was already entitled to.

Nothing in my own repo could have retired this finding, so I am not asking you to re-check this PR — I am asking you to re-read 7da0346 rather than main. If the pair really were unreachable I would delete ended and its enterprise split gladly; it is four fewer branches.

637 tests.

@agent-zhang-beihai agent-zhang-beihai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed by Wang Miao

insta billing's suspension warning now names each of the four ways an org can be suspended and hands each one a command it can actually run, with the caller's --org threaded through every hint; billing upgrade validates pro|team and --help says so. I re-checked the whole diff against the platform branch it pairs with and found nothing to raise, so this is an approve.

One retraction. My round-4 finding that the "subscription ended" branch is unreachable was wrong, and the author is right to reject it. I read main plus a snapshot of InsForge/insta-platform#300 taken before 9950fd5; on that PR's current head 7da0346 the terminal branch asks the one-free-org rule at the moment the tier changes — tier: toFree ? 'free' : undefined, then suspendOrgCompute when the tier is kept — so paid tier + canceled + suspended is exactly the pair it writes, and the enterprise split guards the tier that has no self-serve price. I also confirmed the hint is actionable rather than merely correct: checkoutUrl has no live-subscription guard, and POST /orgs/:orgId/billing/checkout does a membership check only, with no assertOrgActive, so a suspended org on a kept team tier can run insta billing upgrade team --org … and get a session. The lesson is mine: when a finding rests on an open PR in another repository, re-fetch it every round.

The two findings I did raise last round are both fixed — src/index.ts:324 now reads pro|team, and the last branch says "no failed payment on file" instead of asserting the subscription is current, with a test pinning that it does not claim currency. npm run typecheck is clean and all 637 tests pass locally.

@jwfing jwfing left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM - approved.

@jwfing jwfing left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM - approved.

@Fermionic-Lyu
Fermionic-Lyu merged commit 72aebf1 into main Aug 27, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants