diff --git a/backend/admin/users_admin.py b/backend/admin/users_admin.py index 661f4622..e1f1046f 100644 --- a/backend/admin/users_admin.py +++ b/backend/admin/users_admin.py @@ -74,6 +74,7 @@ async def advanced_user_search( ''' id, created_at, + primary_owner_user_id, billing_customers(email), billing_subscriptions(status) ''' @@ -168,10 +169,21 @@ async def advanced_user_search( subscription_status = item['billing_subscriptions'][0].get('status') credit_account = credit_accounts.get(item['id'], {}) + + email = 'N/A' + if item.get('billing_customers') and item['billing_customers'][0].get('email'): + email = item['billing_customers'][0]['email'] + elif item.get('primary_owner_user_id'): + try: + user_email_result = await client.rpc('get_user_email', {'user_id': item['primary_owner_user_id']}).execute() + if user_email_result.data: + email = user_email_result.data + except Exception as e: + logger.warning(f"Failed to get email for account {item['id']}: {e}") users.append(UserSummary( id=item['id'], - email=item['billing_customers'][0]['email'] if item.get('billing_customers') else 'N/A', + email=email, created_at=datetime.fromisoformat(item['created_at'].replace('Z', '+00:00')), tier=credit_account.get('tier', 'free'), credit_balance=float(credit_account.get('balance', 0)), @@ -227,6 +239,7 @@ async def list_users( ''' id, created_at, + primary_owner_user_id, billing_customers(email), billing_subscriptions(status) ''' @@ -238,6 +251,7 @@ async def list_users( ''' id, created_at, + primary_owner_user_id, billing_customers(email), billing_subscriptions(status) ''' @@ -305,9 +319,22 @@ async def list_users( credit_account = credit_accounts.get(item['id'], {}) + # Get email from billing_customers or fetch from auth.users for OAuth users + email = 'N/A' + if item.get('billing_customers') and item['billing_customers'][0].get('email'): + email = item['billing_customers'][0]['email'] + elif item.get('primary_owner_user_id'): + # Try to get email for OAuth users + try: + user_email_result = await client.rpc('get_user_email', {'user_id': item['primary_owner_user_id']}).execute() + if user_email_result.data: + email = user_email_result.data + except Exception as e: + logger.warning(f"Failed to get email for account {item['id']}: {e}") + users.append(UserSummary( id=item['id'], - email=item['billing_customers'][0]['email'] if item.get('billing_customers') else 'N/A', + email=email, created_at=datetime.fromisoformat(item['created_at'].replace('Z', '+00:00')), tier=credit_account.get('tier', 'free'), credit_balance=float(credit_account.get('balance', 0)), @@ -340,6 +367,7 @@ async def get_user_details( ''' id, created_at, + primary_owner_user_id, billing_customers(email), billing_subscriptions(status, created, current_period_end) ''' @@ -350,6 +378,18 @@ async def get_user_details( account = account_result.data[0] + # Get email if not present (OAuth users) + if not account.get('billing_customers') or not account['billing_customers'][0].get('email'): + if account.get('primary_owner_user_id'): + try: + user_email_result = await client.rpc('get_user_email', {'user_id': account['primary_owner_user_id']}).execute() + if user_email_result.data: + if not account.get('billing_customers'): + account['billing_customers'] = [{}] + account['billing_customers'][0]['email'] = user_email_result.data + except Exception as e: + logger.warning(f"Failed to get email for account {user_id}: {e}") + credit_result = await client.from_('credit_accounts').select( 'balance, tier, lifetime_granted, lifetime_purchased, lifetime_used, last_grant_date' ).eq('account_id', user_id).execute() diff --git a/backend/billing/admin.py b/backend/billing/admin.py index df3bc541..790a698d 100644 --- a/backend/billing/admin.py +++ b/backend/billing/admin.py @@ -55,7 +55,19 @@ async def adjust_user_credits( description=f"Admin adjustment: {request.reason}", expires_at=datetime.now(timezone.utc) + timedelta(days=30) if request.is_expiring else None ) - new_balance = result['total_balance'] + if result.get('duplicate_prevented'): + logger.info(f"[ADMIN] Duplicate credit adjustment prevented for {request.account_id}") + balance_info = await credit_manager.get_balance(request.account_id) + return { + 'success': True, + 'message': 'Credit adjustment already processed (duplicate prevented)', + 'new_balance': float(balance_info.get('total', 0)), + 'adjustment_amount': float(request.amount), + 'is_expiring': request.is_expiring, + 'duplicate_prevented': True + } + else: + new_balance = result.get('total_balance', 0) else: result = await credit_manager.use_credits( account_id=request.account_id, @@ -113,10 +125,15 @@ async def grant_credits_to_users( description=f"Admin grant: {request.reason}", expires_at=datetime.now(timezone.utc) + timedelta(days=30) if request.is_expiring else None ) + if result.get('duplicate_prevented'): + balance_info = await credit_manager.get_balance(account_id) + new_balance = balance_info.get('total', 0) + else: + new_balance = result.get('total_balance', 0) results.append({ 'account_id': account_id, 'success': True, - 'new_balance': result['total_balance'] + 'new_balance': new_balance }) except Exception as e: results.append({ @@ -150,6 +167,12 @@ async def process_refund( type='admin_grant' ) + if result.get('duplicate_prevented'): + balance_info = await credit_manager.get_balance(request.account_id) + new_balance = balance_info.get('total_balance', 0) + else: + new_balance = result.get('total_balance', 0) + refund_id = None if request.stripe_refund and request.payment_intent_id: try: @@ -168,7 +191,7 @@ async def process_refund( return { 'success': True, - 'new_balance': float(result['total_balance']), + 'new_balance': float(new_balance), 'refund_amount': float(request.amount), 'stripe_refund_id': refund_id, 'is_expiring': request.is_expiring diff --git a/backend/billing/subscription_service.py b/backend/billing/subscription_service.py index abbb1de5..5803bc06 100644 --- a/backend/billing/subscription_service.py +++ b/backend/billing/subscription_service.py @@ -42,9 +42,29 @@ class SubscriptionService: account = account_result.data[0] user_id = account['primary_owner_user_id'] + + email = None - user_result = await client.auth.admin.get_user_by_id(user_id) - email = user_result.user.email if user_result and user_result.user else None + try: + user_result = await client.auth.admin.get_user_by_id(user_id) + email = user_result.user.email if user_result and user_result.user else None + except Exception as e: + logger.warning(f"Failed to get user via auth.admin API for user {user_id}: {e}") + + if not email: + try: + user_email_result = await client.rpc('get_user_email', {'user_id': user_id}).execute() + if user_email_result.data: + email = user_email_result.data + except Exception as e: + logger.warning(f"Failed to get email via RPC for user {user_id}: {e}") + + if not email: + logger.error(f"Could not find email for user {user_id} / account {account_id}") + raise HTTPException( + status_code=400, + detail="Unable to retrieve user email. Please ensure your account has a valid email address." + ) customer = await stripe.Customer.create_async( email=email, @@ -57,7 +77,7 @@ class SubscriptionService: 'email': email }).execute() - logger.info(f"Created Stripe customer {customer.id} for account {account_id}") + logger.info(f"Created Stripe customer {customer.id} for account {account_id} with email {email}") return customer.id async def get_subscription(self, account_id: str) -> Dict: diff --git a/backend/supabase/migrations/20250910102751_fix_oauth_email_retrieval.sql b/backend/supabase/migrations/20250910102751_fix_oauth_email_retrieval.sql new file mode 100644 index 00000000..8b566382 --- /dev/null +++ b/backend/supabase/migrations/20250910102751_fix_oauth_email_retrieval.sql @@ -0,0 +1,90 @@ +-- NOTE: This migration has been rolled back by 20250910103000_rollback_oauth_email_fix.sql +BEGIN; + +CREATE OR REPLACE FUNCTION public.get_user_email(user_id UUID) +RETURNS TEXT +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public +AS $$ +DECLARE + user_email TEXT; +BEGIN + SELECT email INTO user_email + FROM auth.users + WHERE id = user_id; + + IF user_email IS NULL THEN + SELECT + COALESCE( + raw_user_meta_data->>'email', + raw_user_meta_data->>'user_email', + email + ) INTO user_email + FROM auth.users + WHERE id = user_id; + END IF; + + RETURN user_email; +END; +$$; + +GRANT EXECUTE ON FUNCTION public.get_user_email(UUID) TO authenticated, service_role; + +DO $$ +DECLARE + rec RECORD; + user_email TEXT; +BEGIN + FOR rec IN + SELECT bc.account_id, a.primary_owner_user_id + FROM basejump.billing_customers bc + JOIN basejump.accounts a ON bc.account_id = a.id + WHERE bc.email IS NULL OR bc.email = '' + LOOP + user_email := public.get_user_email(rec.primary_owner_user_id); + + IF user_email IS NOT NULL THEN + UPDATE basejump.billing_customers + SET email = user_email + WHERE account_id = rec.account_id; + + RAISE NOTICE 'Updated email for account %: %', rec.account_id, user_email; + END IF; + END LOOP; +END; +$$; + +CREATE INDEX IF NOT EXISTS idx_auth_users_email ON auth.users(email); + +CREATE OR REPLACE FUNCTION basejump.ensure_billing_customer_email() +RETURNS TRIGGER +LANGUAGE plpgsql +AS $$ +DECLARE + user_email TEXT; + owner_id UUID; +BEGIN + IF NEW.email IS NULL OR NEW.email = '' THEN + SELECT primary_owner_user_id INTO owner_id + FROM basejump.accounts + WHERE id = NEW.account_id; + + user_email := public.get_user_email(owner_id); + + IF user_email IS NOT NULL THEN + NEW.email := user_email; + END IF; + END IF; + + RETURN NEW; +END; +$$; + +DROP TRIGGER IF EXISTS ensure_billing_customer_email_trigger ON basejump.billing_customers; +CREATE TRIGGER ensure_billing_customer_email_trigger + BEFORE INSERT OR UPDATE ON basejump.billing_customers + FOR EACH ROW + EXECUTE FUNCTION basejump.ensure_billing_customer_email(); + +COMMIT; \ No newline at end of file diff --git a/backend/supabase/migrations/20250910103000_rollback_oauth_email_fix.sql b/backend/supabase/migrations/20250910103000_rollback_oauth_email_fix.sql new file mode 100644 index 00000000..4d51fb8c --- /dev/null +++ b/backend/supabase/migrations/20250910103000_rollback_oauth_email_fix.sql @@ -0,0 +1,9 @@ +BEGIN; + +DROP TRIGGER IF EXISTS ensure_billing_customer_email_trigger ON basejump.billing_customers; + +DROP FUNCTION IF EXISTS basejump.ensure_billing_customer_email(); + +DROP FUNCTION IF EXISTS public.get_user_email(UUID); + +COMMIT; \ No newline at end of file diff --git a/backend/supabase/migrations/20250910105021_fix_oauth_email_retrieval.sql b/backend/supabase/migrations/20250910105021_fix_oauth_email_retrieval.sql new file mode 100644 index 00000000..4e766d54 --- /dev/null +++ b/backend/supabase/migrations/20250910105021_fix_oauth_email_retrieval.sql @@ -0,0 +1,89 @@ +BEGIN; + +CREATE OR REPLACE FUNCTION public.get_user_email(user_id UUID) +RETURNS TEXT +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public +AS $$ +DECLARE + user_email TEXT; +BEGIN + SELECT email INTO user_email + FROM auth.users + WHERE id = user_id; + + IF user_email IS NULL THEN + SELECT + COALESCE( + raw_user_meta_data->>'email', + raw_user_meta_data->>'user_email', + email + ) INTO user_email + FROM auth.users + WHERE id = user_id; + END IF; + + RETURN user_email; +END; +$$; + +GRANT EXECUTE ON FUNCTION public.get_user_email(UUID) TO authenticated, service_role; + +DO $$ +DECLARE + rec RECORD; + user_email TEXT; +BEGIN + FOR rec IN + SELECT bc.account_id, a.primary_owner_user_id + FROM basejump.billing_customers bc + JOIN basejump.accounts a ON bc.account_id = a.id + WHERE bc.email IS NULL OR bc.email = '' + LOOP + user_email := public.get_user_email(rec.primary_owner_user_id); + + IF user_email IS NOT NULL THEN + UPDATE basejump.billing_customers + SET email = user_email + WHERE account_id = rec.account_id; + + RAISE NOTICE 'Updated email for account %: %', rec.account_id, user_email; + END IF; + END LOOP; +END; +$$; + +CREATE INDEX IF NOT EXISTS idx_auth_users_email ON auth.users(email); + +CREATE OR REPLACE FUNCTION basejump.ensure_billing_customer_email() +RETURNS TRIGGER +LANGUAGE plpgsql +AS $$ +DECLARE + user_email TEXT; + owner_id UUID; +BEGIN + IF NEW.email IS NULL OR NEW.email = '' THEN + SELECT primary_owner_user_id INTO owner_id + FROM basejump.accounts + WHERE id = NEW.account_id; + + user_email := public.get_user_email(owner_id); + + IF user_email IS NOT NULL THEN + NEW.email := user_email; + END IF; + END IF; + + RETURN NEW; +END; +$$; + +DROP TRIGGER IF EXISTS ensure_billing_customer_email_trigger ON basejump.billing_customers; +CREATE TRIGGER ensure_billing_customer_email_trigger + BEFORE INSERT OR UPDATE ON basejump.billing_customers + FOR EACH ROW + EXECUTE FUNCTION basejump.ensure_billing_customer_email(); + +COMMIT; \ No newline at end of file diff --git a/frontend/src/components/admin/admin-user-details-dialog.tsx b/frontend/src/components/admin/admin-user-details-dialog.tsx index 5c8a0b7b..f218ff0a 100644 --- a/frontend/src/components/admin/admin-user-details-dialog.tsx +++ b/frontend/src/components/admin/admin-user-details-dialog.tsx @@ -359,87 +359,8 @@ export function AdminUserDetailsDialog({ - -
- - - - - Adjust Credits - - - -
-
- -

- Use positive amounts to add credits, negative to deduct. -

-
-
-
- - setAdjustAmount(e.target.value)} - /> -
-
- -