Spaces:
Runtime error
Runtime error
| """ | |
| Subscription service for managing user subscriptions and Stripe integration | |
| """ | |
| from sqlalchemy.orm import Session | |
| from typing import Optional, Dict, Any | |
| from datetime import datetime | |
| import logging | |
| from ..models import User, Subscription, SubscriptionPlan | |
| from .credit_service import credit_service | |
| from .plan_service import plan_service | |
| logger = logging.getLogger(__name__) | |
| class SubscriptionService: | |
| """Service for managing user subscriptions""" | |
| def get_user_subscription(self, db: Session, user_id: int) -> Optional[Subscription]: | |
| """ | |
| Get the active subscription for a user | |
| Args: | |
| db: Database session | |
| user_id: User ID | |
| Returns: | |
| Subscription object or None if not found | |
| """ | |
| return ( | |
| db.query(Subscription) | |
| .filter(Subscription.user_id == user_id) | |
| .order_by(Subscription.created_at.desc()) | |
| .first() | |
| ) | |
| def create_or_update_subscription( | |
| self, | |
| db: Session, | |
| user: User, | |
| plan_id: int, | |
| stripe_subscription_data: Dict[str, Any] | |
| ) -> Subscription: | |
| """ | |
| Create or update a subscription from Stripe webhook data | |
| Args: | |
| db: Database session | |
| user: User object | |
| plan_id: SubscriptionPlan ID | |
| stripe_subscription_data: Data from Stripe subscription object | |
| Returns: | |
| Created or updated Subscription object | |
| """ | |
| # Check if subscription exists | |
| existing = self.get_user_subscription(db, user.id) | |
| # Extract Stripe data | |
| stripe_sub_id = stripe_subscription_data.get("id") | |
| stripe_customer_id = stripe_subscription_data.get("customer") | |
| status = stripe_subscription_data.get("status", "active") | |
| current_period_start = stripe_subscription_data.get("current_period_start") | |
| current_period_end = stripe_subscription_data.get("current_period_end") | |
| cancel_at_period_end = stripe_subscription_data.get("cancel_at_period_end", False) | |
| # Convert timestamps if they exist | |
| period_start = None | |
| period_end = None | |
| if current_period_start: | |
| period_start = datetime.fromtimestamp(current_period_start) | |
| if current_period_end: | |
| period_end = datetime.fromtimestamp(current_period_end) | |
| if existing: | |
| # Update existing subscription | |
| existing.plan_id = plan_id | |
| existing.status = status | |
| existing.stripe_customer_id = stripe_customer_id | |
| existing.stripe_subscription_id = stripe_sub_id | |
| existing.current_period_start = period_start | |
| existing.current_period_end = period_end | |
| existing.cancel_at_period_end = cancel_at_period_end | |
| existing.updated_at = datetime.utcnow() | |
| db.commit() | |
| db.refresh(existing) | |
| logger.info(f"Updated subscription for user {user.id} to plan {plan_id}") | |
| return existing | |
| else: | |
| # Create new subscription | |
| subscription = Subscription( | |
| user_id=user.id, | |
| plan_id=plan_id, | |
| status=status, | |
| stripe_customer_id=stripe_customer_id, | |
| stripe_subscription_id=stripe_sub_id, | |
| current_period_start=period_start, | |
| current_period_end=period_end, | |
| cancel_at_period_end=cancel_at_period_end | |
| ) | |
| db.add(subscription) | |
| db.commit() | |
| db.refresh(subscription) | |
| logger.info(f"Created subscription for user {user.id} with plan {plan_id}") | |
| return subscription | |
| def cancel_subscription( | |
| self, | |
| db: Session, | |
| user_id: int, | |
| immediate: bool = False | |
| ) -> Optional[Subscription]: | |
| """ | |
| Cancel a user's subscription | |
| Args: | |
| db: Database session | |
| user_id: User ID | |
| immediate: If True, cancel immediately; if False, cancel at period end | |
| Returns: | |
| Updated Subscription object or None if not found | |
| """ | |
| subscription = self.get_user_subscription(db, user_id) | |
| if not subscription: | |
| logger.warning(f"No subscription found for user {user_id}") | |
| return None | |
| if immediate: | |
| subscription.status = "canceled" | |
| subscription.cancel_at_period_end = False | |
| else: | |
| subscription.cancel_at_period_end = True | |
| subscription.updated_at = datetime.utcnow() | |
| db.commit() | |
| db.refresh(subscription) | |
| logger.info(f"Canceled subscription for user {user_id} (immediate={immediate})") | |
| return subscription | |
| def downgrade_to_free(self, db: Session, user_id: int) -> Subscription: | |
| """ | |
| Downgrade a user to the free tier | |
| Args: | |
| db: Database session | |
| user_id: User ID | |
| Returns: | |
| Updated or created Subscription object | |
| """ | |
| # Get free plan | |
| free_plan = plan_service.get_plan_by_name(db, "Free") | |
| if not free_plan: | |
| raise ValueError("Free plan not found in database") | |
| # Get or create subscription | |
| subscription = self.get_user_subscription(db, user_id) | |
| if subscription: | |
| subscription.plan_id = free_plan.id | |
| subscription.status = "active" | |
| subscription.stripe_subscription_id = None | |
| subscription.cancel_at_period_end = False | |
| subscription.updated_at = datetime.utcnow() | |
| db.commit() | |
| db.refresh(subscription) | |
| else: | |
| subscription = Subscription( | |
| user_id=user_id, | |
| plan_id=free_plan.id, | |
| status="active" | |
| ) | |
| db.add(subscription) | |
| db.commit() | |
| db.refresh(subscription) | |
| # Reset credits to free tier | |
| credit_service.reset_credits( | |
| db, | |
| user_id, | |
| free_plan.id, | |
| description="Downgraded to Free tier" | |
| ) | |
| logger.info(f"Downgraded user {user_id} to Free tier") | |
| return subscription | |
| def assign_free_tier(self, db: Session, user: User) -> Subscription: | |
| """ | |
| Assign free tier to a new user | |
| Args: | |
| db: Database session | |
| user: User object | |
| Returns: | |
| Created Subscription object | |
| """ | |
| # Get free plan | |
| free_plan = plan_service.get_plan_by_name(db, "Free") | |
| if not free_plan: | |
| raise ValueError("Free plan not found in database") | |
| # Create subscription | |
| subscription = Subscription( | |
| user_id=user.id, | |
| plan_id=free_plan.id, | |
| status="active" | |
| ) | |
| db.add(subscription) | |
| db.commit() | |
| db.refresh(subscription) | |
| # Initialize credits | |
| credit_service.reset_credits( | |
| db, | |
| user.id, | |
| free_plan.id, | |
| description="New user - Free tier" | |
| ) | |
| logger.info(f"Assigned Free tier to new user {user.id}") | |
| return subscription | |
| def handle_payment_succeeded( | |
| self, | |
| db: Session, | |
| user_id: int, | |
| stripe_invoice_data: Dict[str, Any] | |
| ) -> None: | |
| """ | |
| Handle successful payment (monthly renewal) - reset credits | |
| Args: | |
| db: Database session | |
| user_id: User ID | |
| stripe_invoice_data: Data from Stripe invoice object | |
| """ | |
| subscription = self.get_user_subscription(db, user_id) | |
| if not subscription or not subscription.plan_id: | |
| logger.warning(f"No subscription or plan found for user {user_id}") | |
| return | |
| # Reset credits to plan limit | |
| credit_service.reset_credits( | |
| db, | |
| user_id, | |
| subscription.plan_id, | |
| description="Monthly subscription renewal" | |
| ) | |
| logger.info(f"Reset credits for user {user_id} after successful payment") | |
| def handle_payment_failed( | |
| self, | |
| db: Session, | |
| user_id: int, | |
| stripe_invoice_data: Dict[str, Any] | |
| ) -> None: | |
| """ | |
| Handle failed payment | |
| Args: | |
| db: Database session | |
| user_id: User ID | |
| stripe_invoice_data: Data from Stripe invoice object | |
| """ | |
| subscription = self.get_user_subscription(db, user_id) | |
| if not subscription: | |
| logger.warning(f"No subscription found for user {user_id}") | |
| return | |
| # Mark subscription as past_due | |
| subscription.status = "past_due" | |
| subscription.updated_at = datetime.utcnow() | |
| db.commit() | |
| logger.warning(f"Payment failed for user {user_id}, subscription marked as past_due") | |
| def change_plan( | |
| self, | |
| db: Session, | |
| user_id: int, | |
| new_plan_id: int, | |
| billing_period: str = "monthly" | |
| ) -> Subscription: | |
| """ | |
| Change user's subscription plan immediately with proration | |
| Args: | |
| db: Database session | |
| user_id: User ID | |
| new_plan_id: New plan ID to switch to | |
| billing_period: "monthly" or "yearly" | |
| Returns: | |
| Updated Subscription object | |
| """ | |
| import stripe | |
| subscription = self.get_user_subscription(db, user_id) | |
| if not subscription or not subscription.stripe_subscription_id: | |
| raise ValueError("No active Stripe subscription found") | |
| # Get new plan | |
| new_plan = plan_service.get_plan_by_id(db, new_plan_id) | |
| if not new_plan: | |
| raise ValueError(f"Plan {new_plan_id} not found") | |
| # Get price ID based on billing period | |
| if billing_period.lower() == "yearly": | |
| new_price_id = new_plan.stripe_price_id_yearly | |
| else: | |
| new_price_id = new_plan.stripe_price_id_monthly or new_plan.stripe_price_id | |
| if not new_price_id: | |
| raise ValueError(f"Plan {new_plan.name} does not have a {billing_period} price ID") | |
| # Get current Stripe subscription | |
| stripe_sub = stripe.Subscription.retrieve(subscription.stripe_subscription_id) | |
| # Get current subscription item ID | |
| current_item_id = stripe_sub["items"]["data"][0]["id"] | |
| # Modify subscription in Stripe with immediate proration | |
| updated_sub = stripe.Subscription.modify( | |
| subscription.stripe_subscription_id, | |
| items=[{ | |
| "id": current_item_id, | |
| "price": new_price_id, | |
| }], | |
| proration_behavior="always_invoice", | |
| metadata={ | |
| "user_id": str(user_id), | |
| "plan_id": str(new_plan_id), | |
| "billing_period": billing_period | |
| } | |
| ) | |
| # Update database subscription | |
| subscription.plan_id = new_plan_id | |
| subscription.status = updated_sub["status"] | |
| subscription.current_period_start = datetime.fromtimestamp(updated_sub["current_period_start"]) | |
| subscription.current_period_end = datetime.fromtimestamp(updated_sub["current_period_end"]) | |
| subscription.updated_at = datetime.utcnow() | |
| db.commit() | |
| db.refresh(subscription) | |
| # Reset credits to new plan limit | |
| credit_service.reset_credits( | |
| db, | |
| user_id, | |
| new_plan_id, | |
| description=f"Plan changed to {new_plan.name} ({billing_period})" | |
| ) | |
| logger.info(f"Changed plan for user {user_id} to {new_plan.name} ({billing_period})") | |
| return subscription | |
| def reactivate_subscription( | |
| self, | |
| db: Session, | |
| user_id: int | |
| ) -> Optional[Subscription]: | |
| """ | |
| Reactivate a canceled subscription | |
| Args: | |
| db: Database session | |
| user_id: User ID | |
| Returns: | |
| Updated Subscription object or None if not found | |
| """ | |
| import stripe | |
| subscription = self.get_user_subscription(db, user_id) | |
| if not subscription or not subscription.stripe_subscription_id: | |
| logger.warning(f"No subscription found for user {user_id}") | |
| return None | |
| # Reactivate in Stripe | |
| stripe.Subscription.modify( | |
| subscription.stripe_subscription_id, | |
| cancel_at_period_end=False | |
| ) | |
| # Update database | |
| subscription.cancel_at_period_end = False | |
| subscription.updated_at = datetime.utcnow() | |
| db.commit() | |
| db.refresh(subscription) | |
| logger.info(f"Reactivated subscription for user {user_id}") | |
| return subscription | |
| def get_subscription_info(self, db: Session, user_id: int) -> Dict[str, Any]: | |
| """ | |
| Get comprehensive subscription information for a user | |
| Args: | |
| db: Database session | |
| user_id: User ID | |
| Returns: | |
| Dictionary with subscription details | |
| """ | |
| subscription = self.get_user_subscription(db, user_id) | |
| # Get all available plans | |
| all_plans = plan_service.get_all_active_plans(db) | |
| available_plans = [ | |
| plan_service.get_plan_info(db, plan.id) | |
| for plan in all_plans | |
| ] | |
| if not subscription: | |
| return { | |
| "has_subscription": False, | |
| "status": None, | |
| "plan": None, | |
| "credits": None, | |
| "available_plans": available_plans, | |
| "current_period_start": None, | |
| "current_period_end": None, | |
| "next_billing_amount": None, | |
| "billing_period": None, | |
| "cancel_at_period_end": False | |
| } | |
| # Get plan info | |
| plan_info = None | |
| if subscription.plan_id: | |
| plan_info = plan_service.get_plan_info(db, subscription.plan_id) | |
| # Get credit info | |
| credit_info = credit_service.get_balance_info(db, user_id) | |
| # Add credits_per_month from plan if available | |
| if plan_info and credit_info: | |
| credit_info["credits_per_month"] = plan_info.get("credits_per_month", 0) | |
| # Determine billing period from Stripe if available | |
| billing_period = None | |
| next_billing_amount = None | |
| if subscription.stripe_subscription_id: | |
| try: | |
| import stripe | |
| stripe_sub = stripe.Subscription.retrieve(subscription.stripe_subscription_id) | |
| # Determine billing period from interval | |
| if stripe_sub.get("items", {}).get("data", []): | |
| interval = stripe_sub["items"]["data"][0]["price"].get("recurring", {}).get("interval") | |
| billing_period = "yearly" if interval == "year" else "monthly" | |
| # Get next billing amount | |
| if stripe_sub.get("items", {}).get("data", []): | |
| price = stripe_sub["items"]["data"][0]["price"] | |
| next_billing_amount = price.get("unit_amount", 0) / 100 # Convert from cents | |
| except Exception as e: | |
| logger.warning(f"Could not fetch Stripe subscription details: {e}") | |
| return { | |
| "has_subscription": True, | |
| "status": subscription.status, | |
| "plan": plan_info, | |
| "credits": credit_info, | |
| "stripe_subscription_id": subscription.stripe_subscription_id, | |
| "current_period_start": subscription.current_period_start.isoformat() if subscription.current_period_start else None, | |
| "current_period_end": subscription.current_period_end.isoformat() if subscription.current_period_end else None, | |
| "cancel_at_period_end": subscription.cancel_at_period_end, | |
| "available_plans": available_plans, | |
| "billing_period": billing_period, | |
| "next_billing_amount": next_billing_amount | |
| } | |
| # Singleton instance | |
| subscription_service = SubscriptionService() | |