From 6b0ffb97668773c11ca2956b7c9da506f5dfa5e5 Mon Sep 17 00:00:00 2001 From: Saumya Date: Mon, 8 Sep 2025 23:35:32 +0530 Subject: [PATCH] refactor billing api --- backend/billing/subscription_service.py | 20 +-- backend/billing/trial_service.py | 7 +- backend/billing/webhook_service.py | 135 ++++++++++++++++-- .../migrations/20250908115623_fix_signup.sql | 1 - ...50908221821_add_cancelled_trial_status.sql | 10 ++ frontend/src/app/docs/self-hosting/page.tsx | 4 - .../react-query/billing/use-trial-status.ts | 1 - 7 files changed, 147 insertions(+), 31 deletions(-) create mode 100644 backend/supabase/migrations/20250908221821_add_cancelled_trial_status.sql diff --git a/backend/billing/subscription_service.py b/backend/billing/subscription_service.py index fd403962..96f46555 100644 --- a/backend/billing/subscription_service.py +++ b/backend/billing/subscription_service.py @@ -396,9 +396,13 @@ class SubscriptionService: return existing_account = await client.from_('credit_accounts').select('trial_status').eq('account_id', account_id).execute() - if existing_account.data and existing_account.data[0].get('trial_status') == 'active': - logger.info(f"[WEBHOOK] Trial already active for account {account_id}, skipping duplicate processing") - return + if existing_account.data: + current_status = existing_account.data[0].get('trial_status') + if current_status == 'active': + logger.info(f"[WEBHOOK] Trial already active for account {account_id}, skipping duplicate processing") + return + elif current_status == 'none': + logger.info(f"[WEBHOOK] Activating trial for account {account_id}") trial_ends_at = datetime.fromtimestamp(subscription.trial_end, tz=timezone.utc) @@ -417,14 +421,10 @@ class SubscriptionService: description=f'{TRIAL_DURATION_DAYS}-day free trial credits' ) - await client.from_('trial_history').insert({ + await client.from_('trial_history').upsert({ 'account_id': account_id, - 'trial_mode': 'cc_required', - 'started_at': datetime.now(timezone.utc).isoformat(), - 'stripe_subscription_id': subscription['id'], - 'had_payment_method': True, - 'credits_granted': str(TRIAL_CREDITS) - }).on_conflict('account_id').do_nothing().execute() + 'started_at': datetime.now(timezone.utc).isoformat() + }, on_conflict='account_id').execute() logger.info(f"[WEBHOOK] Started trial for user {account_id} via Stripe subscription - granted ${TRIAL_CREDITS} credits") diff --git a/backend/billing/trial_service.py b/backend/billing/trial_service.py index 9a935d43..264364e1 100644 --- a/backend/billing/trial_service.py +++ b/backend/billing/trial_service.py @@ -82,7 +82,7 @@ class TrialService: logger.info(f"[TRIAL CANCEL] Cancelled Stripe subscription {stripe_subscription_id} for account {account_id}") await client.from_('credit_accounts').update({ - 'trial_status': 'expired', + 'trial_status': 'cancelled', 'tier': 'none', 'balance': 0.00, 'stripe_subscription_id': None @@ -145,6 +145,7 @@ class TrialService: from .subscription_service import subscription_service customer_id = await subscription_service.get_or_create_stripe_customer(account_id) + logger.info(f"[TRIAL] Creating checkout session for account {account_id}") session = await stripe.checkout.Session.create_async( customer=customer_id, payment_method_types=['card'], @@ -165,6 +166,10 @@ class TrialService: mode='subscription', success_url=success_url, cancel_url=cancel_url, + metadata={ + 'account_id': account_id, + 'trial_start': 'true' + }, subscription_data={ 'trial_period_days': TRIAL_DURATION_DAYS, 'metadata': { diff --git a/backend/billing/webhook_service.py b/backend/billing/webhook_service.py index f6bcb1cd..4d8d9ca6 100644 --- a/backend/billing/webhook_service.py +++ b/backend/billing/webhook_service.py @@ -107,24 +107,111 @@ class WebhookService: logger.error(f"[WEBHOOK] Credit amount: ${credit_amount}") async def _handle_subscription_checkout(self, session, client): - if session.get('metadata', {}).get('trial_start') == 'true': - logger.info(f"[WEBHOOK] Trial checkout detected - will be handled by subscription change event") + logger.info(f"[WEBHOOK] Checkout completed for new subscription: {session['subscription']}") + logger.info(f"[WEBHOOK] Session metadata: {session.get('metadata', {})}") + if session.get('metadata', {}).get('trial_start') == 'true': + account_id = session['metadata'].get('account_id') + logger.info(f"[WEBHOOK] Trial checkout detected for account {account_id}") + + if session.get('subscription'): + subscription_id = session['subscription'] + subscription = stripe.Subscription.retrieve(subscription_id, expand=['default_payment_method']) + + if subscription.status == 'trialing': + logger.info(f"[WEBHOOK] Trial subscription created for account {account_id}") + + price_id = subscription['items']['data'][0]['price']['id'] if subscription.get('items') else None + tier_info = get_tier_by_price_id(price_id) + tier_name = tier_info.name if tier_info else 'tier_2_20' + + trial_ends_at = datetime.fromtimestamp(subscription.trial_end, tz=timezone.utc) if subscription.get('trial_end') else None + + await client.from_('credit_accounts').update({ + 'trial_status': 'active', + 'trial_started_at': datetime.now(timezone.utc).isoformat(), + 'trial_ends_at': trial_ends_at.isoformat() if trial_ends_at else None, + 'stripe_subscription_id': subscription['id'], + 'tier': tier_name + }).eq('account_id', account_id).execute() + + logger.info(f"[WEBHOOK] Activated trial for account {account_id}, tier: {tier_name}") + + await credit_manager.add_credits( + account_id=account_id, + amount=TRIAL_CREDITS, + is_expiring=False, + description=f'{TRIAL_DURATION_DAYS}-day free trial credits' + ) + + await client.from_('trial_history').upsert({ + 'account_id': account_id, + 'started_at': datetime.now(timezone.utc).isoformat(), + 'stripe_checkout_session_id': session.get('id') + }, on_conflict='account_id').execute() + + logger.info(f"[WEBHOOK] Trial fully activated for account {account_id}") + else: + logger.info(f"[WEBHOOK] Subscription status: {subscription.status}, not trialing") + async def _handle_subscription_created_or_updated(self, event, client): subscription = event.data.object logger.info(f"[WEBHOOK] Subscription event: type={event.type}, status={subscription.status}") + logger.info(f"[WEBHOOK] Subscription metadata: {subscription.get('metadata', {})}") if event.type == 'customer.subscription.updated': await self._handle_subscription_updated(event, subscription, client) if subscription.status in ['active', 'trialing']: logger.info(f"[WEBHOOK] Processing subscription change for customer: {subscription['customer']}") + + if subscription.status == 'trialing' and not subscription.get('metadata', {}).get('account_id'): + customer_result = await client.schema('basejump').from_('billing_customers')\ + .select('account_id')\ + .eq('id', subscription['customer'])\ + .execute() + + if customer_result.data and customer_result.data[0].get('account_id'): + account_id = customer_result.data[0]['account_id'] + logger.info(f"[WEBHOOK] Found account_id {account_id} for customer {subscription['customer']}") + try: + await stripe.Subscription.modify_async( + subscription['id'], + metadata={'account_id': account_id, 'trial_start': 'true'} + ) + subscription['metadata'] = {'account_id': account_id, 'trial_start': 'true'} + logger.info(f"[WEBHOOK] Updated subscription metadata with account_id") + except Exception as e: + logger.error(f"[WEBHOOK] Failed to update subscription metadata: {e}") + from .subscription_service import subscription_service await subscription_service.handle_subscription_change(subscription) async def _handle_subscription_updated(self, event, subscription, client): previous_attributes = event.data.get('previous_attributes', {}) prev_status = previous_attributes.get('status') + prev_default_payment = previous_attributes.get('default_payment_method') + + if subscription.status == 'trialing' and subscription.get('default_payment_method') and not prev_default_payment: + account_id = subscription.metadata.get('account_id') + if account_id: + logger.info(f"[WEBHOOK] Payment method added to trial for account {account_id}") + + price_id = subscription['items']['data'][0]['price']['id'] if subscription.get('items') else None + tier_info = get_tier_by_price_id(price_id) + tier_name = tier_info.name if tier_info else 'tier_2_20' + + await client.from_('credit_accounts').update({ + 'trial_status': 'converted', + 'tier': tier_name + }).eq('account_id', account_id).execute() + + await client.from_('trial_history').update({ + 'converted_to_paid': True, + 'ended_at': datetime.now(timezone.utc).isoformat() + }).eq('account_id', account_id).is_('ended_at', 'null').execute() + + logger.info(f"[WEBHOOK] Marked trial as converted (payment added) for account {account_id}, tier: {tier_name}") if prev_status == 'trialing' and subscription.status != 'trialing': account_id = subscription.metadata.get('account_id') @@ -133,16 +220,36 @@ class WebhookService: if subscription.status == 'active': logger.info(f"[WEBHOOK] Trial converted to paid for account {account_id}") + price_id = subscription['items']['data'][0]['price']['id'] + tier_info = get_tier_by_price_id(price_id) + tier_name = tier_info.name if tier_info else 'tier_2_20' + await client.from_('credit_accounts').update({ 'trial_status': 'converted', - 'tier': 'tier_2_20' + 'tier': tier_name }).eq('account_id', account_id).execute() await client.from_('trial_history').update({ 'ended_at': datetime.now(timezone.utc).isoformat(), 'converted_to_paid': True + }).eq('account_id', account_id).is_('ended_at', 'null').execute() + + logger.info(f"[WEBHOOK] Trial conversion completed for account {account_id} to tier {tier_name}") + + elif subscription.status == 'canceled': + logger.info(f"[WEBHOOK] Trial cancelled for account {account_id}") + + await client.from_('credit_accounts').update({ + 'trial_status': 'cancelled', + 'tier': 'none', + 'stripe_subscription_id': None }).eq('account_id', account_id).execute() + await client.from_('trial_history').update({ + 'ended_at': datetime.now(timezone.utc).isoformat(), + 'converted_to_paid': False + }).eq('account_id', account_id).is_('ended_at', 'null').execute() + else: logger.info(f"[WEBHOOK] Trial expired without conversion for account {account_id}, status: {subscription.status}") @@ -156,7 +263,7 @@ class WebhookService: await client.from_('trial_history').update({ 'ended_at': datetime.now(timezone.utc).isoformat(), 'converted_to_paid': False - }).eq('account_id', account_id).execute() + }).eq('account_id', account_id).is_('ended_at', 'null').execute() await client.from_('credit_ledger').insert({ 'account_id': account_id, @@ -303,9 +410,13 @@ class WebhookService: return existing_account = await client.from_('credit_accounts').select('trial_status').eq('account_id', account_id).execute() - if existing_account.data and existing_account.data[0].get('trial_status') == 'active': - logger.info(f"[WEBHOOK] Trial already active for account {account_id}, skipping duplicate processing") - return + if existing_account.data: + current_status = existing_account.data[0].get('trial_status') + if current_status == 'active': + logger.info(f"[WEBHOOK] Trial already active for account {account_id}, skipping duplicate processing") + return + elif current_status == 'none': + logger.info(f"[WEBHOOK] Activating trial for account {account_id}") trial_ends_at = datetime.fromtimestamp(subscription.trial_end, tz=timezone.utc) @@ -324,14 +435,10 @@ class WebhookService: description=f'{TRIAL_DURATION_DAYS}-day free trial credits' ) - await client.from_('trial_history').insert({ + await client.from_('trial_history').upsert({ 'account_id': account_id, - 'trial_mode': 'cc_required', - 'started_at': datetime.now(timezone.utc).isoformat(), - 'stripe_subscription_id': subscription['id'], - 'had_payment_method': True, - 'credits_granted': str(TRIAL_CREDITS) - }).on_conflict('account_id').do_nothing().execute() + 'started_at': datetime.now(timezone.utc).isoformat() + }, on_conflict='account_id').execute() logger.info(f"[WEBHOOK] Started trial for user {account_id} via Stripe subscription - granted ${TRIAL_CREDITS} credits") diff --git a/backend/supabase/migrations/20250908115623_fix_signup.sql b/backend/supabase/migrations/20250908115623_fix_signup.sql index 452bd3d7..6be633f7 100644 --- a/backend/supabase/migrations/20250908115623_fix_signup.sql +++ b/backend/supabase/migrations/20250908115623_fix_signup.sql @@ -21,7 +21,6 @@ BEGIN ON CONFLICT (account_id) DO NOTHING; RAISE LOG 'Created account for new user % with no credits (must start trial)', NEW.id; END IF; - RETURN NEW; EXCEPTION WHEN OTHERS THEN RAISE WARNING 'Error in initialize_free_tier_credits for user %: %', NEW.id, SQLERRM; diff --git a/backend/supabase/migrations/20250908221821_add_cancelled_trial_status.sql b/backend/supabase/migrations/20250908221821_add_cancelled_trial_status.sql new file mode 100644 index 00000000..d58e7eda --- /dev/null +++ b/backend/supabase/migrations/20250908221821_add_cancelled_trial_status.sql @@ -0,0 +1,10 @@ +BEGIN; + +ALTER TABLE credit_accounts +DROP CONSTRAINT IF EXISTS credit_accounts_trial_status_check; + +ALTER TABLE credit_accounts +ADD CONSTRAINT credit_accounts_trial_status_check +CHECK (trial_status IN ('none', 'active', 'expired', 'converted', 'cancelled')); + +COMMIT; diff --git a/frontend/src/app/docs/self-hosting/page.tsx b/frontend/src/app/docs/self-hosting/page.tsx index e6d21b16..6a34c9a9 100644 --- a/frontend/src/app/docs/self-hosting/page.tsx +++ b/frontend/src/app/docs/self-hosting/page.tsx @@ -119,7 +119,6 @@ cd suna`

Step 3: Provide Your Credentials

The wizard will walk you through configuring these services in 17 steps. Don't worry - it saves your progress!

-

Required Services

@@ -130,7 +129,6 @@ cd suna`

Self-hosted option available. Handles user data, conversations, and agent configs.

Get it at: supabase.com

-
@@ -139,7 +137,6 @@ cd suna`

Provides secure environments for agents to run code safely.

Get it at: daytona.io

-
@@ -272,7 +269,6 @@ docker compose down`
-

Terminal 2 - Frontend:

{