Backend: - User authentication with JWT tokens (auth_service.py) - Subscription plans: Free, Starter (), Pro (), Business (), Enterprise - Stripe integration for payments (payment_service.py) - Usage tracking and quotas - Credit packages for pay-per-use - Plan-based provider restrictions Frontend: - Landing page with hero, features, pricing preview (landing-sections.tsx) - Pricing page with all plans and credit packages (/pricing) - User dashboard with usage stats (/dashboard) - Login/Register pages with validation (/auth/login, /auth/register) - Ollama self-hosting setup guide (/ollama-setup) - Updated sidebar with user section and plan badge Monetization strategy: - Freemium: 3 docs/day, Ollama only - Starter: 50 docs/month, Google Translate - Pro: 200 docs/month, all providers, API access - Business: 1000 docs/month, team management - Enterprise: Custom pricing, SLA Self-hosted option: - Free unlimited usage with own Ollama server - Complete privacy (data never leaves machine) - Step-by-step setup guide included
299 lines
9.4 KiB
Python
299 lines
9.4 KiB
Python
"""
|
|
Stripe payment integration for subscriptions and credits
|
|
"""
|
|
import os
|
|
from typing import Optional, Dict, Any
|
|
from datetime import datetime
|
|
|
|
# Try to import stripe
|
|
try:
|
|
import stripe
|
|
STRIPE_AVAILABLE = True
|
|
except ImportError:
|
|
STRIPE_AVAILABLE = False
|
|
stripe = None
|
|
|
|
from models.subscription import PlanType, PLANS, CREDIT_PACKAGES, SubscriptionStatus
|
|
from services.auth_service import get_user_by_id, update_user, add_credits
|
|
|
|
|
|
# Stripe configuration
|
|
STRIPE_SECRET_KEY = os.getenv("STRIPE_SECRET_KEY", "")
|
|
STRIPE_WEBHOOK_SECRET = os.getenv("STRIPE_WEBHOOK_SECRET", "")
|
|
STRIPE_PUBLISHABLE_KEY = os.getenv("STRIPE_PUBLISHABLE_KEY", "")
|
|
|
|
if STRIPE_AVAILABLE and STRIPE_SECRET_KEY:
|
|
stripe.api_key = STRIPE_SECRET_KEY
|
|
|
|
|
|
def is_stripe_configured() -> bool:
|
|
"""Check if Stripe is properly configured"""
|
|
return STRIPE_AVAILABLE and bool(STRIPE_SECRET_KEY)
|
|
|
|
|
|
async def create_checkout_session(
|
|
user_id: str,
|
|
plan: PlanType,
|
|
billing_period: str = "monthly", # monthly or yearly
|
|
success_url: str = "",
|
|
cancel_url: str = "",
|
|
) -> Optional[Dict[str, Any]]:
|
|
"""Create a Stripe checkout session for subscription"""
|
|
if not is_stripe_configured():
|
|
return {"error": "Stripe not configured", "demo_mode": True}
|
|
|
|
user = get_user_by_id(user_id)
|
|
if not user:
|
|
return {"error": "User not found"}
|
|
|
|
plan_config = PLANS[plan]
|
|
price_id = plan_config[f"stripe_price_id_{billing_period}"]
|
|
|
|
if not price_id:
|
|
return {"error": "Plan not available for purchase"}
|
|
|
|
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,
|
|
metadata={"user_id": user_id}
|
|
)
|
|
customer_id = customer.id
|
|
update_user(user_id, {"stripe_customer_id": customer_id})
|
|
|
|
# Create checkout session
|
|
session = stripe.checkout.Session.create(
|
|
customer=customer_id,
|
|
mode="subscription",
|
|
payment_method_types=["card"],
|
|
line_items=[{"price": price_id, "quantity": 1}],
|
|
success_url=success_url or f"{os.getenv('FRONTEND_URL', 'http://localhost:3000')}/dashboard?session_id={{CHECKOUT_SESSION_ID}}",
|
|
cancel_url=cancel_url or f"{os.getenv('FRONTEND_URL', 'http://localhost:3000')}/pricing",
|
|
metadata={"user_id": user_id, "plan": plan.value},
|
|
subscription_data={
|
|
"metadata": {"user_id": user_id, "plan": plan.value}
|
|
}
|
|
)
|
|
|
|
return {
|
|
"session_id": session.id,
|
|
"url": session.url
|
|
}
|
|
except Exception as e:
|
|
return {"error": str(e)}
|
|
|
|
|
|
async def create_credits_checkout(
|
|
user_id: str,
|
|
package_index: int,
|
|
success_url: str = "",
|
|
cancel_url: str = "",
|
|
) -> Optional[Dict[str, Any]]:
|
|
"""Create a Stripe checkout session for credit purchase"""
|
|
if not is_stripe_configured():
|
|
return {"error": "Stripe not configured", "demo_mode": True}
|
|
|
|
if package_index < 0 or package_index >= len(CREDIT_PACKAGES):
|
|
return {"error": "Invalid package"}
|
|
|
|
user = get_user_by_id(user_id)
|
|
if not user:
|
|
return {"error": "User not found"}
|
|
|
|
package = CREDIT_PACKAGES[package_index]
|
|
|
|
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,
|
|
metadata={"user_id": user_id}
|
|
)
|
|
customer_id = customer.id
|
|
update_user(user_id, {"stripe_customer_id": customer_id})
|
|
|
|
# Create checkout session
|
|
session = stripe.checkout.Session.create(
|
|
customer=customer_id,
|
|
mode="payment",
|
|
payment_method_types=["card"],
|
|
line_items=[{"price": package["stripe_price_id"], "quantity": 1}],
|
|
success_url=success_url or f"{os.getenv('FRONTEND_URL', 'http://localhost:3000')}/dashboard?credits=purchased",
|
|
cancel_url=cancel_url or f"{os.getenv('FRONTEND_URL', 'http://localhost:3000')}/pricing",
|
|
metadata={
|
|
"user_id": user_id,
|
|
"credits": package["credits"],
|
|
"type": "credits"
|
|
}
|
|
)
|
|
|
|
return {
|
|
"session_id": session.id,
|
|
"url": session.url
|
|
}
|
|
except Exception as e:
|
|
return {"error": str(e)}
|
|
|
|
|
|
async def handle_webhook(payload: bytes, sig_header: str) -> Dict[str, Any]:
|
|
"""Handle Stripe webhook events"""
|
|
if not is_stripe_configured():
|
|
return {"error": "Stripe not configured"}
|
|
|
|
try:
|
|
event = stripe.Webhook.construct_event(
|
|
payload, sig_header, STRIPE_WEBHOOK_SECRET
|
|
)
|
|
except ValueError:
|
|
return {"error": "Invalid payload"}
|
|
except stripe.error.SignatureVerificationError:
|
|
return {"error": "Invalid signature"}
|
|
|
|
# Handle the event
|
|
if event["type"] == "checkout.session.completed":
|
|
session = event["data"]["object"]
|
|
await handle_checkout_completed(session)
|
|
|
|
elif event["type"] == "customer.subscription.updated":
|
|
subscription = event["data"]["object"]
|
|
await handle_subscription_updated(subscription)
|
|
|
|
elif event["type"] == "customer.subscription.deleted":
|
|
subscription = event["data"]["object"]
|
|
await handle_subscription_deleted(subscription)
|
|
|
|
elif event["type"] == "invoice.payment_failed":
|
|
invoice = event["data"]["object"]
|
|
await handle_payment_failed(invoice)
|
|
|
|
return {"status": "success"}
|
|
|
|
|
|
async def handle_checkout_completed(session: Dict):
|
|
"""Handle successful checkout"""
|
|
metadata = session.get("metadata", {})
|
|
user_id = metadata.get("user_id")
|
|
|
|
if not user_id:
|
|
return
|
|
|
|
# Check if it's a credit purchase
|
|
if metadata.get("type") == "credits":
|
|
credits = int(metadata.get("credits", 0))
|
|
add_credits(user_id, credits)
|
|
return
|
|
|
|
# It's a subscription
|
|
plan = metadata.get("plan")
|
|
if plan:
|
|
subscription_id = session.get("subscription")
|
|
update_user(user_id, {
|
|
"plan": plan,
|
|
"subscription_status": SubscriptionStatus.ACTIVE.value,
|
|
"stripe_subscription_id": subscription_id,
|
|
"docs_translated_this_month": 0, # Reset on new subscription
|
|
"pages_translated_this_month": 0,
|
|
})
|
|
|
|
|
|
async def handle_subscription_updated(subscription: Dict):
|
|
"""Handle subscription updates"""
|
|
metadata = subscription.get("metadata", {})
|
|
user_id = metadata.get("user_id")
|
|
|
|
if not user_id:
|
|
return
|
|
|
|
status_map = {
|
|
"active": SubscriptionStatus.ACTIVE,
|
|
"past_due": SubscriptionStatus.PAST_DUE,
|
|
"canceled": SubscriptionStatus.CANCELED,
|
|
"trialing": SubscriptionStatus.TRIALING,
|
|
"paused": SubscriptionStatus.PAUSED,
|
|
}
|
|
|
|
stripe_status = subscription.get("status", "active")
|
|
status = status_map.get(stripe_status, SubscriptionStatus.ACTIVE)
|
|
|
|
update_user(user_id, {
|
|
"subscription_status": status.value,
|
|
"subscription_ends_at": datetime.fromtimestamp(
|
|
subscription.get("current_period_end", 0)
|
|
).isoformat() if subscription.get("current_period_end") else None
|
|
})
|
|
|
|
|
|
async def handle_subscription_deleted(subscription: Dict):
|
|
"""Handle subscription cancellation"""
|
|
metadata = subscription.get("metadata", {})
|
|
user_id = metadata.get("user_id")
|
|
|
|
if not user_id:
|
|
return
|
|
|
|
update_user(user_id, {
|
|
"plan": PlanType.FREE.value,
|
|
"subscription_status": SubscriptionStatus.CANCELED.value,
|
|
"stripe_subscription_id": None,
|
|
})
|
|
|
|
|
|
async def handle_payment_failed(invoice: Dict):
|
|
"""Handle failed payment"""
|
|
customer_id = invoice.get("customer")
|
|
if not customer_id:
|
|
return
|
|
|
|
# Find user by customer ID and update status
|
|
# In production, query database by stripe_customer_id
|
|
|
|
|
|
async def cancel_subscription(user_id: str) -> Dict[str, Any]:
|
|
"""Cancel a user's subscription"""
|
|
if not is_stripe_configured():
|
|
return {"error": "Stripe not configured"}
|
|
|
|
user = get_user_by_id(user_id)
|
|
if not user or not user.stripe_subscription_id:
|
|
return {"error": "No active subscription"}
|
|
|
|
try:
|
|
# Cancel at period end
|
|
subscription = stripe.Subscription.modify(
|
|
user.stripe_subscription_id,
|
|
cancel_at_period_end=True
|
|
)
|
|
|
|
return {
|
|
"status": "canceling",
|
|
"cancel_at": datetime.fromtimestamp(subscription.cancel_at).isoformat() if subscription.cancel_at else None
|
|
}
|
|
except Exception as e:
|
|
return {"error": str(e)}
|
|
|
|
|
|
async def get_billing_portal_url(user_id: str) -> Optional[str]:
|
|
"""Get Stripe billing portal URL for customer"""
|
|
if not is_stripe_configured():
|
|
return None
|
|
|
|
user = get_user_by_id(user_id)
|
|
if not user or not user.stripe_customer_id:
|
|
return None
|
|
|
|
try:
|
|
session = stripe.billing_portal.Session.create(
|
|
customer=user.stripe_customer_id,
|
|
return_url=f"{os.getenv('FRONTEND_URL', 'http://localhost:3000')}/dashboard"
|
|
)
|
|
return session.url
|
|
except:
|
|
return None
|