OpenHands / OpenHands/enterprise
Billing balance shows $0 / Add Credit fails while LLM access works (org/team scoping mismatch)
Nobody has claimed this yet.
- 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 balance —
get_creditsinenterprise/server/routes/billing.pyreads: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_idcomes from theX-Org-Idheader oruser.current_org_id. If it resolves to an org whose LiteLLM team has no membership/budget row for this user,get_user_team_inforeturnsNone→get_budget_from_team_inforeturns(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
$0balance while their LLM access keeps working. - "Add Credit" can either error or (via the success-callback path) credit the wrong team.
Suggested direction
- 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_creditsand the LLM key path agree on the sameteam_id). - Checkout/success org consistency: in
success_callback, usebilling_session.org_id(the org the checkout was created for) instead ofuser.current_org_idwhen 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_idtheir active LiteLLM key is bound to
If team_id != current_org_id, that confirms hypothesis (1).
References
enterprise/server/routes/billing.py—get_credits,create_checkout_session,success_callbackenterprise/storage/lite_llm_manager.py—get_user_team_info,get_budget_from_team_info,update_team_and_users_budgetenterprise/integrations/stripe_service.py—find_or_create_customer_by_user_id,_resolve_org_for_userenterprise/server/auth/org_context.py—resolve_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
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- 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