pounce/backend/app/services/stripe_service.py
yves.gugger 88eca582e5 feat: Remove ALL mock data - real scraped data only
MOCK DATA REMOVED:
- Removed ALL hardcoded auction data from auctions.py
- Now uses real-time scraping from ExpiredDomains.net
- Database stores scraped auctions (domain_auctions table)
- Scraping runs hourly via scheduler (:30 each hour)

AUCTION SCRAPER SERVICE:
- Web scraping from ExpiredDomains.net (aggregator)
- Rate limiting per platform (10 req/min)
- Database caching to minimize requests
- Cleanup of ended auctions (auto-deactivate)
- Scrape logging for monitoring

STRIPE INTEGRATION:
- Full payment flow: Checkout → Webhook → Subscription update
- Customer Portal for managing subscriptions
- Price IDs configurable via env vars
- Handles: checkout.completed, subscription.updated/deleted, payment.failed

EMAIL SERVICE (SMTP):
- Beautiful HTML email templates with pounce branding
- Domain available alerts
- Price change notifications
- Subscription confirmations
- Weekly digest emails
- Configurable via SMTP_* env vars

NEW SUBSCRIPTION TIERS:
- Scout (Free): 5 domains, daily checks
- Trader (€19/mo): 50 domains, hourly, portfolio, valuation
- Tycoon (€49/mo): 500+ domains, realtime, API, bulk tools

DATABASE CHANGES:
- domain_auctions table for scraped data
- auction_scrape_logs for monitoring
- stripe_customer_id on users
- stripe_subscription_id on subscriptions
- portfolio_domain relationships fixed

ENV VARS ADDED:
- STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET
- STRIPE_PRICE_TRADER, STRIPE_PRICE_TYCOON
- SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASSWORD
- SMTP_FROM_EMAIL, SMTP_FROM_NAME
2025-12-08 14:08:52 +01:00

367 lines
12 KiB
Python

"""
Stripe Payment Service
Handles subscription payments for pounce.ch
Subscription Tiers:
- Scout (Free): $0/month
- Trader: €19/month (or ~$21)
- Tycoon: €49/month (or ~$54)
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
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": "EUR",
"max_domains": 5,
"check_frequency": "daily",
"portfolio_domains": 0,
"features": ["Basic monitoring", "Daily checks", "Email alerts"],
},
"trader": {
"name": "Trader",
"price": 19,
"currency": "EUR",
"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": 49,
"currency": "EUR",
"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
- 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:
raise ValueError("Invalid payload")
except stripe.error.SignatureVerificationError:
raise ValueError("Invalid signature")
event_type = event["type"]
data = event["data"]["object"]
logger.info(f"Processing Stripe webhook: {event_type}")
if event_type == "checkout.session.completed":
await StripeService._handle_checkout_complete(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)
return {"status": "success", "event_type": event_type}
@staticmethod
async def _handle_checkout_complete(data: Dict, db: AsyncSession):
"""Handle successful checkout - activate subscription."""
user_id = data.get("metadata", {}).get("user_id")
plan = data.get("metadata", {}).get("plan")
customer_id = data.get("customer")
subscription_id = data.get("subscription")
if not user_id or not plan:
logger.error("Missing user_id or plan in checkout metadata")
return
# 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
# 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, TIER_FEATURES["scout"])
if subscription:
subscription.tier = plan
subscription.is_active = True
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()
else:
subscription = Subscription(
user_id=user.id,
tier=plan,
is_active=True,
stripe_subscription_id=subscription_id,
max_domains=tier_info["max_domains"],
check_frequency=tier_info["check_frequency"],
)
db.add(subscription)
await db.commit()
logger.info(f"Activated {plan} subscription for user {user_id}")
@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 = "scout"
subscription.is_active = True # 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()