diff --git a/backend/app/api/subscription.py b/backend/app/api/subscription.py index ea5d6e9..eaa21db 100644 --- a/backend/app/api/subscription.py +++ b/backend/app/api/subscription.py @@ -310,9 +310,13 @@ async def cancel_subscription( """ Cancel subscription and downgrade to Scout. - Note: For Stripe-managed subscriptions, use the Customer Portal instead. - This endpoint is for manual cancellation. + This will: + 1. Cancel the subscription in Stripe (if exists) + 2. Downgrade the user to Scout tier locally """ + from app.services.stripe_service import StripeService + from datetime import datetime + result = await db.execute( select(Subscription).where(Subscription.user_id == current_user.id) ) @@ -330,12 +334,24 @@ async def cancel_subscription( detail="Already on free plan", ) - # Downgrade to Scout old_tier = subscription.tier.value + stripe_sub_id = subscription.stripe_subscription_id + + # Cancel in Stripe first (if we have a Stripe subscription) + if stripe_sub_id: + cancelled = await StripeService.cancel_subscription(stripe_sub_id) + if not cancelled: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to cancel subscription in Stripe. Please try again or contact support.", + ) + + # Downgrade to Scout locally subscription.tier = SubscriptionTier.SCOUT subscription.max_domains = TIER_CONFIG[SubscriptionTier.SCOUT]["domain_limit"] subscription.check_frequency = TIER_CONFIG[SubscriptionTier.SCOUT]["check_frequency"] subscription.stripe_subscription_id = None + subscription.cancelled_at = datetime.utcnow() await db.commit() @@ -343,4 +359,5 @@ async def cancel_subscription( "status": "cancelled", "message": f"Subscription cancelled. Downgraded from {old_tier} to Scout.", "new_tier": "scout", + "stripe_cancelled": bool(stripe_sub_id), } diff --git a/backend/app/services/stripe_service.py b/backend/app/services/stripe_service.py index 5866967..feff099 100644 --- a/backend/app/services/stripe_service.py +++ b/backend/app/services/stripe_service.py @@ -206,6 +206,38 @@ class StripeService: logger.error(f"Stripe error creating portal session: {e}") raise + @staticmethod + async def cancel_subscription(stripe_subscription_id: str) -> bool: + """ + Cancel a subscription in Stripe. + + Args: + stripe_subscription_id: The Stripe subscription ID to cancel + + Returns: + True if cancelled successfully, False otherwise + """ + if not StripeService.is_configured(): + logger.warning("Stripe not configured, skipping cancel") + return False + + if not stripe_subscription_id: + logger.warning("No Stripe subscription ID provided") + return False + + try: + # Cancel the subscription immediately + stripe.Subscription.cancel(stripe_subscription_id) + logger.info(f"Cancelled Stripe subscription: {stripe_subscription_id}") + return True + except stripe.error.InvalidRequestError as e: + # Subscription might already be cancelled + logger.warning(f"Stripe subscription cancel failed (may already be cancelled): {e}") + return True # Consider it success if already cancelled + except stripe.error.StripeError as e: + logger.error(f"Stripe error cancelling subscription: {e}") + return False + @staticmethod async def handle_webhook( payload: bytes,