Skip to content

fix(billing): make a namespace's subscription an optional object - #6947

Merged
gustavosbarreto merged 2 commits into
masterfrom
fix/billing-subscription-state
Aug 25, 2026
Merged

fix(billing): make a namespace's subscription an optional object#6947
gustavosbarreto merged 2 commits into
masterfrom
fix/billing-subscription-state

Conversation

@otavio

@otavio otavio commented Aug 24, 2026

Copy link
Copy Markdown
Member

Pairs with shellhub-io/cloud#2510 (same branch name). Neither side builds without the other.

Why

On Cloud, a namespace that added a payment method but never completed checkout could accept no device and open no SSH session, and the API called it device limit reached. Its free allowance was not reduced — it was zero. See shellhub-io/team#214 for the production analysis: 124 namespaces, 118 accepted devices unreachable.

The cause is in this repo's model. Billing.Status had to carry two unrelated facts: whether a subscription exists at all, and how it is doing. Customer creation wrote inactive there, and every reader had to guess which fact the value meant.

What changes

The subscription becomes an optional object (pkg/models/billing.go).

type Billing struct {
    CustomerID   string
    Subscription *BillingSubscription  // nil = checkout never completed
    CreatedAt, UpdatedAt string
}
  • BillingStatusInactive is gone. No subscription means no status, so the ambiguous state cannot be written.
  • The stored Active boolean is gone. It duplicated Status.IsActive() at five write sites and could drift from the status it mirrored; IsActive() derives it now.
  • Clone() deep-copies. The webhook handlers used billing := *namespace.Billing, which with a pointer field would share the subscription with the namespace and mutate it.
  • HasCutomer is spelled HasCustomer.

Device acceptance stops lying (server/api/services/). BillingEvaluation now names the rule that denied the device, and the two denials get different errors:

Denial Error Console copy
Namespace used its whole allowance ErrDeviceLimit free a slot or upgrade
Subscription denies the device ErrDeviceBillingBlocked finish or repair the subscription; the device count is not the problem

Both stay HTTP 402. The customer in the report deleted all three of their devices because the old message told them to.

Observability (server/ssh/). The banner handler logged a firewall block and a billing block under one message, which made the incident readable only by grouping on the error text. Each case now has its own message, and both carry the tenant. EvaluateBilling logs the block reason.

API contract. namespaceBilling follows the model: subscription nested and optional, active removed, timestamps optional. billingStatus keeps inactive, because that enum is shared with the payment-gateway subscription schema, where the console uses it as its own "no subscription" placeholder.

Test plan

  • go test ./...server/ and root: pass. New: TestValidateBillingForDeviceAcceptance, TestErrDeviceBillingBlocked.
  • golangci-lint run ./... — 0 issues in both modules.
  • go mod tidy — no drift.
  • Console npm run build, lint, test — pass, 3149 tests.

Deploy note

Cloud carries migration 003, which reshapes the stored records. It runs at store construction, under a lock, before the server serves. During a rolling deploy an old replica that reads a migrated row sees an empty status and applies the free-tier math, so the window locks nobody out.

otavio added 2 commits August 24, 2026 17:41
The billing record kept the subscription's Stripe status in a flat `status`
field, which had to carry two unrelated facts: whether a subscription exists
at all, and how the subscription is doing. Customer creation wrote "inactive"
there, so a namespace that added a card but never completed checkout was
indistinguishable from one whose subscription had failed.

`Billing` now holds an optional `Subscription`. No subscription means no
status, so the ambiguous state cannot be written. The stored `Active` boolean
goes with it: it duplicated `Status.IsActive()` at five write sites and could
drift from the status it mirrored. `IsActive()` derives it instead, and
`Clone()` gives callers a deep copy so building the next state cannot mutate
the one the namespace still holds.

The SSH banner handler logged a firewall block and a billing block under one
message, which made a production incident readable only by grouping on the
error text. Each case gets its own message and the tenant.

Ref: shellhub-io/team#214
Device acceptance answered both denials with ErrDeviceLimit, so a namespace
that was well under its allowance was told it had reached a limit. A customer
read that literally: they rejected and deleted all three of their devices,
re-registered the agents, and were refused again, because the device count was
never the cause.

The evaluation now carries which rule denied the device, and the two get
different errors and different console copy. Both stay HTTP 402: the quota
case still asks the user to free a slot or upgrade, and the subscription case
says the device count is not the problem.

Ref: shellhub-io/team#214
@otavio
otavio requested review from a team as code owners August 24, 2026 21:00
@github-actions

github-actions Bot commented Aug 24, 2026

Copy link
Copy Markdown

Code Review Complete

The automated review ran but did not post an updated summary — this usually means no new issues were found since the previous review. If you've pushed changes and want a fresh pass, comment /review.

View job

@gustavosbarreto
gustavosbarreto enabled auto-merge (rebase) August 25, 2026 12:31
@gustavosbarreto
gustavosbarreto merged commit c41ae16 into master Aug 25, 2026
51 of 71 checks passed
@gustavosbarreto
gustavosbarreto deleted the fix/billing-subscription-state branch August 25, 2026 12:32
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