pounce/backend/app/services/stripe_service.py
Yves Gugger 5b99145fb2
Some checks failed
CI / Frontend Lint & Type Check (push) Has been cancelled
CI / Frontend Build (push) Has been cancelled
CI / Backend Lint (push) Has been cancelled
CI / Backend Tests (push) Has been cancelled
CI / Docker Build (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
Deploy / Build & Push Images (push) Has been cancelled
Deploy / Deploy to Server (push) Has been cancelled
Deploy / Notify (push) Has been cancelled
fix: banner position and Sedo affiliate links
2025-12-16 09:02:00 +01:00

551 lines
20 KiB
Python

"""
Stripe Payment Service
Handles subscription payments for pounce.ch
Subscription Tiers:
- Scout (Free): $0/month
- Trader: $9/month
- Tycoon: $29/month
Environment Variables Required:
- STRIPE_SECRET_KEY: Stripe API secret key
- STRIPE_WEBHOOK_SECRET: Webhook signing secret
- STRIPE_PRICE_TRADER: Price ID for Trader plan
- STRIPE_PRICE_TYCOON: Price ID for Tycoon plan
"""
import logging
import os
from typing import Optional, Dict, Any
from datetime import datetime
import stripe
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.user import User
from app.models.subscription import Subscription, SubscriptionTier, SubscriptionStatus
logger = logging.getLogger(__name__)
# Initialize Stripe with API key from environment
stripe.api_key = os.getenv("STRIPE_SECRET_KEY")
# Price IDs from Stripe Dashboard
STRIPE_PRICES = {
"trader": os.getenv("STRIPE_PRICE_TRADER"),
"tycoon": os.getenv("STRIPE_PRICE_TYCOON"),
}
# Subscription tier features
TIER_FEATURES = {
"scout": {
"name": "Scout",
"price": 0,
"currency": "USD",
"max_domains": 5,
"check_frequency": "daily",
"portfolio_domains": 0,
"features": ["Basic monitoring", "Daily checks", "Email alerts"],
},
"trader": {
"name": "Trader",
"price": 9,
"currency": "USD",
"max_domains": 50,
"check_frequency": "hourly",
"portfolio_domains": 25,
"features": [
"50 domain monitoring",
"Hourly checks",
"Portfolio tracking (25 domains)",
"Domain valuation",
"Market insights",
"SMS/Telegram alerts",
],
},
"tycoon": {
"name": "Tycoon",
"price": 29,
"currency": "USD",
"max_domains": 500,
"check_frequency": "realtime",
"portfolio_domains": -1, # Unlimited
"features": [
"500+ domain monitoring",
"10-minute checks",
"Unlimited portfolio",
"API access",
"Bulk tools",
"SEO metrics",
"Priority support",
"Webhooks",
],
},
}
class StripeService:
"""
Handles Stripe payment operations.
Usage:
1. Create checkout session for user
2. User completes payment on Stripe
3. Webhook updates subscription status
"""
@staticmethod
def is_configured() -> bool:
"""Check if Stripe is properly configured."""
return bool(stripe.api_key)
@staticmethod
async def create_checkout_session(
user: User,
plan: str,
success_url: str,
cancel_url: str,
) -> Dict[str, Any]:
"""
Create a Stripe Checkout session for subscription.
Args:
user: User subscribing
plan: "trader" or "tycoon"
success_url: URL to redirect after successful payment
cancel_url: URL to redirect if user cancels
Returns:
Dict with checkout_url and session_id
"""
if not StripeService.is_configured():
raise ValueError("Stripe is not configured. Set STRIPE_SECRET_KEY environment variable.")
if plan not in ["trader", "tycoon"]:
raise ValueError(f"Invalid plan: {plan}. Must be 'trader' or 'tycoon'")
price_id = STRIPE_PRICES.get(plan)
if not price_id:
raise ValueError(f"Price ID not configured for plan: {plan}")
try:
# Create or get Stripe customer
if user.stripe_customer_id:
customer_id = user.stripe_customer_id
else:
customer = stripe.Customer.create(
email=user.email,
name=user.name or user.email,
metadata={
"user_id": str(user.id),
"pounce_user": "true",
}
)
customer_id = customer.id
# Note: Save customer_id to user in calling code
# Create checkout session
session = stripe.checkout.Session.create(
customer=customer_id,
payment_method_types=["card"],
line_items=[
{
"price": price_id,
"quantity": 1,
}
],
mode="subscription",
success_url=success_url,
cancel_url=cancel_url,
metadata={
"user_id": str(user.id),
"plan": plan,
},
subscription_data={
"metadata": {
"user_id": str(user.id),
"plan": plan,
}
},
)
return {
"checkout_url": session.url,
"session_id": session.id,
"customer_id": customer_id,
}
except stripe.error.StripeError as e:
logger.error(f"Stripe error creating checkout: {e}")
raise
@staticmethod
async def create_portal_session(
customer_id: str,
return_url: str,
) -> str:
"""
Create a Stripe Customer Portal session for managing subscription.
Users can:
- Update payment method
- View invoices
- Cancel subscription
"""
if not StripeService.is_configured():
raise ValueError("Stripe is not configured")
try:
session = stripe.billing_portal.Session.create(
customer=customer_id,
return_url=return_url,
)
return session.url
except stripe.error.StripeError as e:
logger.error(f"Stripe error creating portal session: {e}")
raise
@staticmethod
async def handle_webhook(
payload: bytes,
sig_header: str,
db: AsyncSession,
) -> Dict[str, Any]:
"""
Handle Stripe webhook events.
Important events:
- checkout.session.completed: Payment successful (initial)
- invoice.payment_succeeded: Invoice paid (recurring & initial)
- customer.subscription.updated: Subscription changed
- customer.subscription.deleted: Subscription cancelled
- invoice.payment_failed: Payment failed
"""
webhook_secret = os.getenv("STRIPE_WEBHOOK_SECRET")
if not webhook_secret:
raise ValueError("STRIPE_WEBHOOK_SECRET not configured")
try:
event = stripe.Webhook.construct_event(
payload, sig_header, webhook_secret
)
except ValueError:
logger.error("❌ Invalid webhook payload")
raise ValueError("Invalid payload")
except stripe.error.SignatureVerificationError:
logger.error("❌ Invalid webhook signature")
raise ValueError("Invalid signature")
event_type = event["type"]
data = event["data"]["object"]
logger.info(f"🔔 Processing Stripe webhook: {event_type}")
logger.info(f" Event ID: {event.get('id')}")
try:
if event_type == "checkout.session.completed":
await StripeService._handle_checkout_complete(data, db)
elif event_type == "invoice.payment_succeeded":
# This is the main event for successful payments!
await StripeService._handle_invoice_paid(data, db)
elif event_type == "customer.subscription.updated":
await StripeService._handle_subscription_updated(data, db)
elif event_type == "customer.subscription.deleted":
await StripeService._handle_subscription_cancelled(data, db)
elif event_type == "invoice.payment_failed":
await StripeService._handle_payment_failed(data, db)
else:
logger.info(f" Unhandled event type: {event_type} (acknowledged)")
return {"status": "success", "event_type": event_type}
except Exception as e:
logger.exception(f"❌ Error processing webhook {event_type}: {e}")
# Still return success to prevent Stripe from retrying
# The error is logged for investigation
return {"status": "error_logged", "event_type": event_type, "error": str(e)}
@staticmethod
async def _handle_checkout_complete(data: Dict, db: AsyncSession):
"""
Handle successful checkout - activate subscription.
IMPORTANT: This must be idempotent! Stripe may send webhooks multiple times.
"""
session_id = data.get("id")
user_id = data.get("metadata", {}).get("user_id")
plan = data.get("metadata", {}).get("plan") # "trader" or "tycoon"
customer_id = data.get("customer")
subscription_id = data.get("subscription")
logger.info(f"🔔 Checkout complete webhook received:")
logger.info(f" Session: {session_id}")
logger.info(f" User ID: {user_id}")
logger.info(f" Plan: {plan}")
logger.info(f" Customer: {customer_id}")
logger.info(f" Subscription: {subscription_id}")
if not user_id or not plan:
logger.error(f"❌ Missing user_id or plan in checkout metadata: {data.get('metadata')}")
return
# Convert plan string to SubscriptionTier enum
tier_map = {
"trader": SubscriptionTier.TRADER,
"tycoon": SubscriptionTier.TYCOON,
"scout": SubscriptionTier.SCOUT,
}
tier_enum = tier_map.get(plan.lower(), SubscriptionTier.SCOUT)
# Get user
result = await db.execute(
select(User).where(User.id == int(user_id))
)
user = result.scalar_one_or_none()
if not user:
logger.error(f"❌ User {user_id} not found for checkout")
return
logger.info(f" User email: {user.email}")
# IDEMPOTENCY CHECK: Check if this subscription_id was already processed
if subscription_id:
existing_sub = await db.execute(
select(Subscription).where(
Subscription.stripe_subscription_id == subscription_id
)
)
existing = existing_sub.scalar_one_or_none()
if existing:
logger.info(f"⚠️ Subscription {subscription_id} already processed (idempotent)")
return
# Update user's Stripe customer ID
user.stripe_customer_id = customer_id
# Create or update subscription
sub_result = await db.execute(
select(Subscription).where(Subscription.user_id == user.id)
)
subscription = sub_result.scalar_one_or_none()
tier_info = TIER_FEATURES.get(plan.lower(), TIER_FEATURES["scout"])
if subscription:
# Only upgrade if actually changing
old_tier = subscription.tier
subscription.tier = tier_enum
subscription.status = SubscriptionStatus.ACTIVE
subscription.stripe_subscription_id = subscription_id
subscription.max_domains = tier_info["max_domains"]
subscription.check_frequency = tier_info["check_frequency"]
subscription.updated_at = datetime.utcnow()
logger.info(f"✅ Updated subscription: {old_tier}{tier_enum}")
else:
subscription = Subscription(
user_id=user.id,
tier=tier_enum,
status=SubscriptionStatus.ACTIVE,
stripe_subscription_id=subscription_id,
max_domains=tier_info["max_domains"],
check_frequency=tier_info["check_frequency"],
)
db.add(subscription)
logger.info(f"✅ Created new subscription: {tier_enum}")
try:
await db.commit()
logger.info(f"✅ Activated {plan} subscription for user {user_id} ({user.email})")
except Exception as e:
logger.exception(f"❌ Failed to commit subscription: {e}")
await db.rollback()
raise
@staticmethod
async def _handle_invoice_paid(data: Dict, db: AsyncSession):
"""
Handle successful invoice payment.
This is the MAIN event for activating subscriptions!
Called for both initial payments and recurring payments.
Invoice structure has metadata in:
- parent.subscription_details.metadata (for subscription invoices)
- lines.data[0].metadata (line item level)
"""
invoice_id = data.get("id")
customer_id = data.get("customer")
customer_email = data.get("customer_email")
billing_reason = data.get("billing_reason") # "subscription_create", "subscription_cycle", etc.
logger.info(f"🧾 Invoice paid webhook received:")
logger.info(f" Invoice: {invoice_id}")
logger.info(f" Customer: {customer_id}")
logger.info(f" Email: {customer_email}")
logger.info(f" Billing reason: {billing_reason}")
# Extract metadata from subscription details
parent = data.get("parent", {})
subscription_details = parent.get("subscription_details", {})
metadata = subscription_details.get("metadata", {})
subscription_id = subscription_details.get("subscription")
user_id = metadata.get("user_id")
plan = metadata.get("plan")
# Fallback: try to get from line items
if not user_id or not plan:
lines = data.get("lines", {}).get("data", [])
if lines:
line_metadata = lines[0].get("metadata", {})
user_id = user_id or line_metadata.get("user_id")
plan = plan or line_metadata.get("plan")
logger.info(f" User ID: {user_id}")
logger.info(f" Plan: {plan}")
logger.info(f" Subscription ID: {subscription_id}")
if not user_id or not plan:
logger.warning(f"⚠️ No user_id or plan in invoice metadata, skipping")
logger.warning(f" Full parent: {parent}")
return
# Convert plan string to SubscriptionTier enum
tier_map = {
"trader": SubscriptionTier.TRADER,
"tycoon": SubscriptionTier.TYCOON,
"scout": SubscriptionTier.SCOUT,
}
tier_enum = tier_map.get(plan.lower(), SubscriptionTier.SCOUT)
# Get user
result = await db.execute(
select(User).where(User.id == int(user_id))
)
user = result.scalar_one_or_none()
if not user:
logger.error(f"❌ User {user_id} not found for invoice")
return
logger.info(f" Found user: {user.email}")
# Update user's Stripe customer ID if not set
if not user.stripe_customer_id:
user.stripe_customer_id = customer_id
# IDEMPOTENCY CHECK: Check if this subscription_id was already processed with this tier
if subscription_id:
existing_sub = await db.execute(
select(Subscription).where(
Subscription.stripe_subscription_id == subscription_id,
Subscription.tier == tier_enum
)
)
existing = existing_sub.scalar_one_or_none()
if existing:
logger.info(f"⚠️ Subscription {subscription_id} already active as {tier_enum} (idempotent)")
return
# Create or update subscription
sub_result = await db.execute(
select(Subscription).where(Subscription.user_id == user.id)
)
subscription = sub_result.scalar_one_or_none()
tier_info = TIER_FEATURES.get(plan.lower(), TIER_FEATURES["scout"])
if subscription:
old_tier = subscription.tier
subscription.tier = tier_enum
subscription.status = SubscriptionStatus.ACTIVE
subscription.stripe_subscription_id = subscription_id
subscription.max_domains = tier_info["max_domains"]
subscription.check_frequency = tier_info["check_frequency"]
subscription.updated_at = datetime.utcnow()
logger.info(f"✅ Updated subscription: {old_tier}{tier_enum}")
else:
subscription = Subscription(
user_id=user.id,
tier=tier_enum,
status=SubscriptionStatus.ACTIVE,
stripe_subscription_id=subscription_id,
max_domains=tier_info["max_domains"],
check_frequency=tier_info["check_frequency"],
)
db.add(subscription)
logger.info(f"✅ Created new subscription: {tier_enum}")
try:
await db.commit()
logger.info(f"✅ Activated {plan} subscription for user {user_id} ({user.email}) via invoice")
except Exception as e:
logger.exception(f"❌ Failed to commit subscription: {e}")
await db.rollback()
raise
@staticmethod
async def _handle_subscription_updated(data: Dict, db: AsyncSession):
"""Handle subscription update (plan change, renewal, etc.)."""
subscription_id = data.get("id")
status = data.get("status")
result = await db.execute(
select(Subscription).where(
Subscription.stripe_subscription_id == subscription_id
)
)
subscription = result.scalar_one_or_none()
if subscription:
subscription.is_active = status == "active"
subscription.updated_at = datetime.utcnow()
await db.commit()
logger.info(f"Updated subscription {subscription_id}: status={status}")
@staticmethod
async def _handle_subscription_cancelled(data: Dict, db: AsyncSession):
"""Handle subscription cancellation - downgrade to Scout."""
subscription_id = data.get("id")
result = await db.execute(
select(Subscription).where(
Subscription.stripe_subscription_id == subscription_id
)
)
subscription = result.scalar_one_or_none()
if subscription:
subscription.tier = SubscriptionTier.SCOUT # Use enum
subscription.status = SubscriptionStatus.ACTIVE # Scout is still active
subscription.stripe_subscription_id = None
subscription.max_domains = TIER_FEATURES["scout"]["max_domains"]
subscription.check_frequency = TIER_FEATURES["scout"]["check_frequency"]
subscription.updated_at = datetime.utcnow()
await db.commit()
logger.info(f"Cancelled subscription, downgraded to Scout: {subscription_id}")
@staticmethod
async def _handle_payment_failed(data: Dict, db: AsyncSession):
"""Handle failed payment - send notification, eventually downgrade."""
subscription_id = data.get("subscription")
# Log the failure - in production, send email notification
logger.warning(f"Payment failed for subscription: {subscription_id}")
# After multiple failures, Stripe will cancel the subscription
# which triggers _handle_subscription_cancelled
# Global instance
stripe_service = StripeService()