OpenHands / OpenHands/enterprise

Billing balance shows $0 / Add Credit fails while LLM access works (org/team scoping mismatch)

Open
#166 1 comment 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

bug OHE-Agent python
Dominant language
Python
Stars
4
Forks
2
Avg merge
1d 22h
Merged PRs (30d)
101

Description

Summary

A user reported that their billing page shows a $0 balance and "Add Credit" errors out, even though their model access works fine (LLM calls succeed, so their LiteLLM budget is clearly non-zero). The user had changed their account email at some point (started on an email tied to their GitHub identity, later switched to a different address on the cloud account).

The original hypothesis was that the billing page resolves the user by email in a less resilient way than the conversation/LLM path. After tracing the code, the real divergence is not email→user resolution — both paths resolve the user by the stable Keycloak sub (user_id). The divergence is which org/team the balance is read from vs. which team the working LLM key is bound to.

Code paths compared

Both the conversation/LLM path and the billing path resolve the user by the stable Keycloak user_id, so at the "which user" level they are equally resilient to email changes. The asymmetry is one level down — the org/team scoping:

  • LLM calls work because the user's LiteLLM key is a long-lived key bound to the (user_id, org/team) it was minted under. As long as that team has budget, calls succeed regardless of email changes or which org is currently "selected."

  • Billing balanceget_credits in enterprise/server/routes/billing.py reads:

    user_team_info = await LiteLlmManager.get_user_team_info(user_id, str(effective_org_id))
    max_budget, spend = LiteLlmManager.get_budget_from_team_info(user_team_info, user_id, str(effective_org_id))
    

    effective_org_id comes from the X-Org-Id header or user.current_org_id. If it resolves to an org whose LiteLLM team has no membership/budget row for this user, get_user_team_info returns Noneget_budget_from_team_info returns (0, 0)the balance renders $0, even though the user's working key (bound to a different team) has budget. This reproduces the exact symptom: "balance is zero but model access works."

Second, related inconsistency (potential lost credits)

In the Stripe success callback (success_callback in enterprise/server/routes/billing.py), the budget is read and updated using user.current_org_id:

user = await UserStore.get_user_by_id(billing_session.user_id)
user_team_info = await LiteLlmManager.get_user_team_info(billing_session.user_id, str(user.current_org_id))
...
await LiteLlmManager.update_team_and_users_budget(str(user.current_org_id), new_max_budget)

…whereas create_checkout_session created the customer/session against effective_org_id (which may be the X-Org-Id header) and stored billing_session.org_id = customer_info['org_id'].

If effective_org_id != current_org_id at purchase time, credits are added to a different team than the one the checkout was scoped to — money in, balance still looks wrong. The success path should credit the billing_session.org_id it was created for, not re-derive the org from user.current_org_id.

Impact

  • Users who changed emails and/or had org / personal-workspace churn can see a $0 balance while their LLM access keeps working.
  • "Add Credit" can either error or (via the success-callback path) credit the wrong team.

Suggested direction

  1. Balance read consistency: when the effective-org team has no budget row for the user, fall back to the team where the user's active/funded LiteLLM key actually lives (or otherwise make get_credits and the LLM key path agree on the same team_id).
  2. Checkout/success org consistency: in success_callback, use billing_session.org_id (the org the checkout was created for) instead of user.current_org_id when reading and updating the budget.

Reproduction / data needed to confirm

To confirm which trigger hit the reporting user, we need:

  • their keycloak_user_id
  • their current_org_id
  • the team_id their active LiteLLM key is bound to

If team_id != current_org_id, that confirms hypothesis (1).

References

  • enterprise/server/routes/billing.pyget_credits, create_checkout_session, success_callback
  • enterprise/storage/lite_llm_manager.pyget_user_team_info, get_budget_from_team_info, update_team_and_users_budget
  • enterprise/integrations/stripe_service.pyfind_or_create_customer_by_user_id, _resolve_org_for_user
  • enterprise/server/auth/org_context.pyresolve_effective_org_id (X-Org-Id / current_org_id precedence)

This issue was created by an AI agent (OpenHands) on behalf of the user, based on an investigation of a reported billing/credits problem.

Contributor guide

No contributing guide indexed for this repository

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start in enterprise/server/routes/billing.py with get_credits, create_checkout_session, and success_callback, then read resolve_effective_org_id and the LiteLlmManager helpers named in the issue. Trace effective_org_id, billing_session.org_id, user.current_org_id, and the active key's team through both paths. Done means balance reads and Stripe success credit the checkout's intended team, with the reported mismatch covered by regression tests.

Written by the indexing model from the issue text.

Assessment

Tech stack
python
Domain
backend, payments
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Quiet
Clarity
Mostly clear
Newbie friendliness
56/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.