billing: name the right cause when an org is suspended - #153
Conversation
`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>
2889eb4 to
673e124
Compare
There was a problem hiding this comment.
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-code — src/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-code — src/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>
|
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" branchCorrect, and the sharpest part of the finding is that my own fixture pinned the wrong output: The state is real and I made it: Now: tier first, then status. Tier still leads, for the legacy-row reason you verified in round 2 — 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 commandTaken — The same defect was in the console, so InsForge/insta-frontend#303 now carries the identical three-way split ( 630 tests pass here, 821 there. |
There was a problem hiding this comment.
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-code — git 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-code — src/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.
There was a problem hiding this comment.
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
…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>
|
Both fixed. ✅ The commit adds
|
There was a problem hiding this comment.
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-code — src/commands/billing.ts:25-53,66-83 (resolveOrgId, billingLines, billingUpgrade, presentUrl), src/api.ts:105-125 (requireProject → autoResolveProject), 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-code — src/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.
There was a problem hiding this comment.
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>
There was a problem hiding this comment.
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
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>
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>
There was a problem hiding this comment.
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
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>
There was a problem hiding this comment.
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-code — src/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-code — src/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-code — src/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>
|
Two taken, one rejected on evidence. ✅
|
There was a problem hiding this comment.
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.
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 billingprints one warning for every suspended org: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
suspendOrgComputehas exactly one call path per cause (accrue's free branch for a spent wallet,StripeService.syncSubscriptionfor a lapsed subscription).Why not
subscriptionStatus, which is right there in the payloadRows written before the platform change carry
unpaidalongsidetier: '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 billingnow 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.unpaidbeside tierfreewith no migration.insta billing portal, ended means resubscribe (enterprise contacts support), and no failed payment on file means contact support.--org, sincebillingand the suggested commands resolve the target independently.billing upgradenow acceptspro|team, matching the checkout route and the--helptext; it previously rejectedteamand offeredenterprise, which the server rejects..next/is gitignored so stray turbopack output can't be swept into commits again.Written for commit 0686bd9. Summary will update on new commits.