diff --git a/README.md b/README.md index b5295a9c82..bd8c8ce97c 100644 --- a/README.md +++ b/README.md @@ -54,7 +54,7 @@ The experience will be familiar to anyone who has used Devin or Jules. ### OpenHands Cloud This is a deployment of OpenHands GUI, running on hosted infrastructure. -You can try it with a free $10 credit by [signing in with your GitHub or GitLab account](https://app.all-hands.dev). +You can try it for free using the Minimax model by [signing in with your GitHub or GitLab account](https://app.all-hands.dev). OpenHands Cloud comes with source-available features and integrations: - Integrations with Slack, Jira, and Linear diff --git a/enterprise/migrations/versions/095_drop_pending_free_credits.py b/enterprise/migrations/versions/095_drop_pending_free_credits.py new file mode 100644 index 0000000000..607d6c2a81 --- /dev/null +++ b/enterprise/migrations/versions/095_drop_pending_free_credits.py @@ -0,0 +1,37 @@ +"""Drop pending_free_credits column from org table. + +Revision ID: 095 +Revises: 094 +Create Date: 2025-02-18 00:00:00.000000 + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = '095' +down_revision: Union[str, None] = '094' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # Drop the pending_free_credits column from org table. + # This column was used for tracking free credit eligibility but is no longer needed. + op.drop_column('org', 'pending_free_credits') + + +def downgrade() -> None: + # Re-add pending_free_credits column with default false. + op.add_column( + 'org', + sa.Column( + 'pending_free_credits', + sa.Boolean, + nullable=False, + server_default=sa.text('false'), + ), + ) diff --git a/enterprise/server/constants.py b/enterprise/server/constants.py index d89593a09d..670f28c34a 100644 --- a/enterprise/server/constants.py +++ b/enterprise/server/constants.py @@ -61,8 +61,6 @@ SUBSCRIPTION_PRICE_DATA = { }, } -FREE_CREDIT_THRESHOLD = float(os.environ.get('FREE_CREDIT_THRESHOLD', '10')) -FREE_CREDIT_AMOUNT = float(os.environ.get('FREE_CREDIT_AMOUNT', '10')) STRIPE_API_KEY = os.environ.get('STRIPE_API_KEY', None) REQUIRE_PAYMENT = os.environ.get('REQUIRE_PAYMENT', '0') in ('1', 'true') diff --git a/enterprise/server/routes/billing.py b/enterprise/server/routes/billing.py index 61231a5e01..781d542189 100644 --- a/enterprise/server/routes/billing.py +++ b/enterprise/server/routes/billing.py @@ -9,11 +9,7 @@ from fastapi import APIRouter, Depends, HTTPException, Request, status from fastapi.responses import RedirectResponse from integrations import stripe_service from pydantic import BaseModel -from server.constants import ( - FREE_CREDIT_AMOUNT, - FREE_CREDIT_THRESHOLD, - STRIPE_API_KEY, -) +from server.constants import STRIPE_API_KEY from server.logger import logger from starlette.datastructures import URL from storage.billing_session import BillingSession @@ -151,7 +147,7 @@ async def create_customer_setup_session( customer=customer_info['customer_id'], mode='setup', payment_method_types=['card'], - success_url=f'{base_url}?free_credits=success', + success_url=f'{base_url}?setup=success', cancel_url=f'{base_url}', ) return CreateBillingSessionResponse(redirect_url=checkout_session.url) @@ -260,24 +256,6 @@ async def success_callback(session_id: str, request: Request): org = session.query(Org).filter(Org.id == user.current_org_id).first() new_max_budget = max_budget + add_credits - # Grant free credits if: - # 1. The org has pending free credits (new org, eligible) - # 2. The budget after this purchase meets the threshold - should_grant_free_credits = ( - org and org.pending_free_credits and new_max_budget >= FREE_CREDIT_THRESHOLD - ) - if should_grant_free_credits: - new_max_budget += FREE_CREDIT_AMOUNT - org.pending_free_credits = False - logger.info( - 'free_credits_granted', - extra={ - 'user_id': billing_session.user_id, - 'org_id': str(user.current_org_id), - 'free_credit_amount': FREE_CREDIT_AMOUNT, - }, - ) - await LiteLlmManager.update_team_and_users_budget( str(user.current_org_id), new_max_budget ) @@ -299,7 +277,6 @@ async def success_callback(session_id: str, request: Request): 'org_id': str(user.current_org_id), 'checkout_session_id': billing_session.id, 'stripe_customer_id': stripe_session.customer, - 'free_credits_granted': should_grant_free_credits, }, ) session.commit() diff --git a/enterprise/storage/org.py b/enterprise/storage/org.py index bd20cb45d3..e52be4d6f0 100644 --- a/enterprise/storage/org.py +++ b/enterprise/storage/org.py @@ -47,7 +47,6 @@ class Org(Base): # type: ignore conversation_expiration = Column(Integer, nullable=True) condenser_max_size = Column(Integer, nullable=True) byor_export_enabled = Column(Boolean, nullable=False, default=False) - pending_free_credits = Column(Boolean, nullable=False, default=False) # Relationships org_members = relationship('OrgMember', back_populates='org') diff --git a/enterprise/storage/org_service.py b/enterprise/storage/org_service.py index b358c78d12..144d636a83 100644 --- a/enterprise/storage/org_service.py +++ b/enterprise/storage/org_service.py @@ -112,7 +112,6 @@ class OrgService: contact_email=contact_email, org_version=ORG_SETTINGS_VERSION, default_llm_model=get_default_litellm_model(), - pending_free_credits=True, ) @staticmethod diff --git a/enterprise/storage/user_store.py b/enterprise/storage/user_store.py index 379f02e45e..2be32fb7ca 100644 --- a/enterprise/storage/user_store.py +++ b/enterprise/storage/user_store.py @@ -59,7 +59,6 @@ class UserStore: or user_info.get('preferred_username', ''), contact_email=user_info['email'], v1_enabled=True, - pending_free_credits=True, ) session.add(org) @@ -196,7 +195,6 @@ class UserStore: or user_info.get('username', ''), contact_email=user_info['email'], byor_export_enabled=has_completed_billing, - pending_free_credits=not has_completed_billing, ) session.add(org) diff --git a/enterprise/tests/unit/test_billing.py b/enterprise/tests/unit/test_billing.py index 902af81e15..fd28f4b644 100644 --- a/enterprise/tests/unit/test_billing.py +++ b/enterprise/tests/unit/test_billing.py @@ -291,7 +291,7 @@ async def test_success_callback_stripe_incomplete(): @pytest.mark.asyncio async def test_success_callback_success(): - """Test successful payment completion and credit update (bonus already granted).""" + """Test successful payment completion and credit update.""" mock_request = Request(scope={'type': 'http'}) mock_request._base_url = URL('http://test.com/') @@ -300,7 +300,6 @@ async def test_success_callback_success(): mock_billing_session.user_id = 'mock_user' mock_org = MagicMock() - mock_org.pending_free_credits = False # Not eligible (old org or already granted) with ( patch('server.routes.billing.session_maker') as mock_session_maker, @@ -347,10 +346,10 @@ async def test_success_callback_success(): == 'https://test.com/settings/billing?checkout=success' ) - # Verify LiteLLM API calls - no bonus since not eligible + # Verify LiteLLM API calls mock_update_budget.assert_called_once_with( 'mock_org_id', - 125.0, # 100 + 25.00 (no bonus) + 125.0, # 100 + 25.00 ) # Verify BYOR export is enabled for the org (updated in same session) @@ -363,92 +362,6 @@ async def test_success_callback_success(): mock_db_session.commit.assert_called_once() -@pytest.mark.asyncio -@pytest.mark.parametrize( - 'initial_budget,purchase_cents,pending_credits,expected_final_budget,expected_pending_after', - [ - # New user buys $10 -> gets free credits, pending becomes False - (0, 1000, True, 20.0, False), - # New user buys $5 -> below threshold, no free credits yet, pending stays True - (0, 500, True, 5.0, True), - # User with $5 buys $5 more -> reaches threshold, gets free credits - (5.0, 500, True, 20.0, False), - # User with $5 buys $3 -> below threshold, no free credits yet - (5.0, 300, True, 8.0, True), - # Old user (not pending) buys $25 -> no free credits, stays False - (20.0, 2500, False, 45.0, False), - ], - ids=[ - 'new_user_buys_10_gets_free_credits', - 'new_user_buys_5_below_threshold', - 'user_with_5_buys_5_reaches_threshold', - 'user_with_5_buys_3_below_threshold', - 'old_user_not_eligible', - ], -) -async def test_success_callback_free_credits( - initial_budget, - purchase_cents, - pending_credits, - expected_final_budget, - expected_pending_after, -): - """Test free credits are granted only when pending and threshold is met.""" - mock_request = Request(scope={'type': 'http'}) - mock_request._base_url = URL('http://test.com/') - - mock_billing_session = MagicMock() - mock_billing_session.status = 'in_progress' - mock_billing_session.user_id = 'mock_user' - - mock_org = MagicMock() - mock_org.pending_free_credits = pending_credits - - with ( - patch('server.routes.billing.session_maker') as mock_session_maker, - patch('stripe.checkout.Session.retrieve') as mock_stripe_retrieve, - patch( - 'storage.user_store.UserStore.get_user_by_id_async', - new_callable=AsyncMock, - return_value=MagicMock(current_org_id='mock_org_id'), - ), - patch( - 'storage.lite_llm_manager.LiteLlmManager.get_user_team_info', - return_value={ - 'spend': 0, - 'litellm_budget_table': {'max_budget': initial_budget}, - }, - ), - patch( - 'storage.lite_llm_manager.LiteLlmManager.update_team_and_users_budget' - ) as mock_update_budget, - patch('server.routes.billing.FREE_CREDIT_THRESHOLD', 10.0), - patch('server.routes.billing.FREE_CREDIT_AMOUNT', 10.0), - ): - mock_db_session = MagicMock() - mock_query_chain_billing = MagicMock() - mock_query_chain_billing.filter.return_value.filter.return_value.first.return_value = mock_billing_session - mock_query_chain_org = MagicMock() - mock_query_chain_org.filter.return_value.first.return_value = mock_org - mock_db_session.query.side_effect = [ - mock_query_chain_billing, - mock_query_chain_org, - ] - mock_session_maker.return_value.__enter__.return_value = mock_db_session - - mock_stripe_retrieve.return_value = MagicMock( - status='complete', - amount_subtotal=purchase_cents, - customer='mock_customer_id', - ) - - response = await success_callback('test_session_id', mock_request) - - assert response.status_code == 302 - mock_update_budget.assert_called_once_with('mock_org_id', expected_final_budget) - assert mock_org.pending_free_credits is expected_pending_after - - @pytest.mark.asyncio async def test_success_callback_lite_llm_error(): """Test handling of LiteLLM API errors during success callback.""" @@ -491,11 +404,10 @@ async def test_success_callback_lite_llm_error(): @pytest.mark.asyncio async def test_success_callback_lite_llm_update_budget_error_rollback(): - """Test that pending_free_credits change is not committed when update_team_and_users_budget fails. + """Test that database changes are not committed when update_team_and_users_budget fails. - This test verifies that if LiteLlmManager.update_team_and_users_budget raises an exception - after pending_free_credits has been set to False, the database transaction rolls back and - pending_free_credits remains True. + This test verifies that if LiteLlmManager.update_team_and_users_budget raises an exception, + the database transaction rolls back. """ mock_request = Request(scope={'type': 'http'}) mock_request._base_url = URL('http://test.com/') @@ -505,7 +417,6 @@ async def test_success_callback_lite_llm_update_budget_error_rollback(): mock_billing_session.user_id = 'mock_user' mock_org = MagicMock() - mock_org.pending_free_credits = True with ( patch('server.routes.billing.session_maker') as mock_session_maker, @@ -526,8 +437,6 @@ async def test_success_callback_lite_llm_update_budget_error_rollback(): 'storage.lite_llm_manager.LiteLlmManager.update_team_and_users_budget', side_effect=Exception('LiteLLM API Error'), ), - patch('server.routes.billing.FREE_CREDIT_THRESHOLD', 10.0), - patch('server.routes.billing.FREE_CREDIT_AMOUNT', 10.0), ): mock_db_session = MagicMock() mock_query_chain_billing = MagicMock() @@ -540,7 +449,6 @@ async def test_success_callback_lite_llm_update_budget_error_rollback(): ] mock_session_maker.return_value.__enter__.return_value = mock_db_session - # Purchase $10 to reach threshold mock_stripe_retrieve.return_value = MagicMock( status='complete', amount_subtotal=1000, # $10 @@ -671,6 +579,6 @@ async def test_create_customer_setup_session_success(): customer='mock-customer-id', mode='setup', payment_method_types=['card'], - success_url='https://test.com/?free_credits=success', + success_url='https://test.com/?setup=success', cancel_url='https://test.com/', ) diff --git a/enterprise/tests/unit/test_billing_stripe_integration.py b/enterprise/tests/unit/test_billing_stripe_integration.py index 96100e5f2b..7cb4dffc24 100644 --- a/enterprise/tests/unit/test_billing_stripe_integration.py +++ b/enterprise/tests/unit/test_billing_stripe_integration.py @@ -48,7 +48,7 @@ async def test_create_customer_setup_session_uses_customer_id(): customer=customer_id, mode='setup', payment_method_types=['card'], - success_url=f'{request.base_url}?free_credits=success', + success_url=f'{request.base_url}?setup=success', cancel_url=f'{request.base_url}', )