-
- U
+ {user ? (
+
+
+
+ {user.name.charAt(0).toUpperCase()}
+
+
+ {user.name}
+
+ {user.plan.charAt(0).toUpperCase() + user.plan.slice(1)}
+
+
+
+
+
+
-
-
User
-
Translator
+ ) : (
+
+
+
+
+ Sign In
+
+
+
+
+ Get Started Free
+
+
-
+ )}
diff --git a/main.py b/main.py
index 2406e0f..dbe147a 100644
--- a/main.py
+++ b/main.py
@@ -22,6 +22,9 @@ from config import config
from translators import excel_translator, word_translator, pptx_translator
from utils import file_handler, handle_translation_error, DocumentProcessingError
+# Import auth routes
+from routes.auth_routes import router as auth_router
+
# Import SaaS middleware
from middleware.rate_limiting import RateLimitMiddleware, RateLimitManager, RateLimitConfig
from middleware.security import SecurityHeadersMiddleware, RequestLoggingMiddleware, ErrorHandlingMiddleware
@@ -174,6 +177,9 @@ static_dir = Path(__file__).parent / "static"
if static_dir.exists():
app.mount("/static", StaticFiles(directory=str(static_dir)), name="static")
+# Include auth routes
+app.include_router(auth_router)
+
# Custom exception handler for ValidationError
@app.exception_handler(ValidationError)
diff --git a/models/__init__.py b/models/__init__.py
new file mode 100644
index 0000000..f3d9f4b
--- /dev/null
+++ b/models/__init__.py
@@ -0,0 +1 @@
+# Models package
diff --git a/models/subscription.py b/models/subscription.py
new file mode 100644
index 0000000..9ae572d
--- /dev/null
+++ b/models/subscription.py
@@ -0,0 +1,250 @@
+"""
+Subscription and User models for the monetization system
+"""
+from pydantic import BaseModel, EmailStr, Field
+from typing import Optional, List, Dict, Any
+from datetime import datetime
+from enum import Enum
+
+
+class PlanType(str, Enum):
+ FREE = "free"
+ STARTER = "starter"
+ PRO = "pro"
+ BUSINESS = "business"
+ ENTERPRISE = "enterprise"
+
+
+class SubscriptionStatus(str, Enum):
+ ACTIVE = "active"
+ CANCELED = "canceled"
+ PAST_DUE = "past_due"
+ TRIALING = "trialing"
+ PAUSED = "paused"
+
+
+# Plan definitions with limits
+PLANS = {
+ PlanType.FREE: {
+ "name": "Free",
+ "price_monthly": 0,
+ "price_yearly": 0,
+ "docs_per_month": 3,
+ "max_pages_per_doc": 10,
+ "max_file_size_mb": 5,
+ "providers": ["ollama"], # Only self-hosted
+ "features": [
+ "3 documents per day",
+ "Up to 10 pages per document",
+ "Ollama (self-hosted) only",
+ "Basic support via community",
+ ],
+ "api_access": False,
+ "priority_processing": False,
+ "stripe_price_id_monthly": None,
+ "stripe_price_id_yearly": None,
+ },
+ PlanType.STARTER: {
+ "name": "Starter",
+ "price_monthly": 9,
+ "price_yearly": 90, # 2 months free
+ "docs_per_month": 50,
+ "max_pages_per_doc": 50,
+ "max_file_size_mb": 25,
+ "providers": ["ollama", "google", "libre"],
+ "features": [
+ "50 documents per month",
+ "Up to 50 pages per document",
+ "Google Translate included",
+ "LibreTranslate included",
+ "Email support",
+ ],
+ "api_access": False,
+ "priority_processing": False,
+ "stripe_price_id_monthly": "price_starter_monthly",
+ "stripe_price_id_yearly": "price_starter_yearly",
+ },
+ PlanType.PRO: {
+ "name": "Pro",
+ "price_monthly": 29,
+ "price_yearly": 290, # 2 months free
+ "docs_per_month": 200,
+ "max_pages_per_doc": 200,
+ "max_file_size_mb": 100,
+ "providers": ["ollama", "google", "deepl", "openai", "libre"],
+ "features": [
+ "200 documents per month",
+ "Up to 200 pages per document",
+ "All translation providers",
+ "DeepL & OpenAI included",
+ "API access (1000 calls/month)",
+ "Priority email support",
+ ],
+ "api_access": True,
+ "api_calls_per_month": 1000,
+ "priority_processing": True,
+ "stripe_price_id_monthly": "price_pro_monthly",
+ "stripe_price_id_yearly": "price_pro_yearly",
+ },
+ PlanType.BUSINESS: {
+ "name": "Business",
+ "price_monthly": 79,
+ "price_yearly": 790, # 2 months free
+ "docs_per_month": 1000,
+ "max_pages_per_doc": 500,
+ "max_file_size_mb": 250,
+ "providers": ["ollama", "google", "deepl", "openai", "libre", "azure"],
+ "features": [
+ "1000 documents per month",
+ "Up to 500 pages per document",
+ "All translation providers",
+ "Azure Translator included",
+ "Unlimited API access",
+ "Priority processing queue",
+ "Dedicated support",
+ "Team management (up to 5 users)",
+ ],
+ "api_access": True,
+ "api_calls_per_month": -1, # Unlimited
+ "priority_processing": True,
+ "team_seats": 5,
+ "stripe_price_id_monthly": "price_business_monthly",
+ "stripe_price_id_yearly": "price_business_yearly",
+ },
+ PlanType.ENTERPRISE: {
+ "name": "Enterprise",
+ "price_monthly": -1, # Custom
+ "price_yearly": -1,
+ "docs_per_month": -1, # Unlimited
+ "max_pages_per_doc": -1,
+ "max_file_size_mb": -1,
+ "providers": ["ollama", "google", "deepl", "openai", "libre", "azure", "custom"],
+ "features": [
+ "Unlimited documents",
+ "Unlimited pages",
+ "Custom integrations",
+ "On-premise deployment",
+ "SLA guarantee",
+ "24/7 dedicated support",
+ "Custom AI models",
+ "White-label option",
+ ],
+ "api_access": True,
+ "api_calls_per_month": -1,
+ "priority_processing": True,
+ "team_seats": -1, # Unlimited
+ "stripe_price_id_monthly": None, # Contact sales
+ "stripe_price_id_yearly": None,
+ },
+}
+
+
+class User(BaseModel):
+ id: str
+ email: EmailStr
+ name: str
+ password_hash: str
+ created_at: datetime = Field(default_factory=datetime.utcnow)
+ updated_at: datetime = Field(default_factory=datetime.utcnow)
+ email_verified: bool = False
+ avatar_url: Optional[str] = None
+
+ # Subscription info
+ plan: PlanType = PlanType.FREE
+ subscription_status: SubscriptionStatus = SubscriptionStatus.ACTIVE
+ stripe_customer_id: Optional[str] = None
+ stripe_subscription_id: Optional[str] = None
+ subscription_ends_at: Optional[datetime] = None
+
+ # Usage tracking
+ docs_translated_this_month: int = 0
+ pages_translated_this_month: int = 0
+ api_calls_this_month: int = 0
+ usage_reset_date: datetime = Field(default_factory=datetime.utcnow)
+
+ # Extra credits (purchased separately)
+ extra_credits: int = 0 # Each credit = 1 page
+
+ # Settings
+ default_source_lang: str = "auto"
+ default_target_lang: str = "en"
+ default_provider: str = "google"
+
+ # Ollama self-hosted config
+ ollama_endpoint: Optional[str] = None
+ ollama_model: Optional[str] = None
+
+
+class UserCreate(BaseModel):
+ email: EmailStr
+ name: str
+ password: str
+
+
+class UserLogin(BaseModel):
+ email: EmailStr
+ password: str
+
+
+class UserResponse(BaseModel):
+ id: str
+ email: EmailStr
+ name: str
+ avatar_url: Optional[str] = None
+ plan: PlanType
+ subscription_status: SubscriptionStatus
+ docs_translated_this_month: int
+ pages_translated_this_month: int
+ api_calls_this_month: int
+ extra_credits: int
+ created_at: datetime
+
+ # Plan limits for display
+ plan_limits: Dict[str, Any] = {}
+
+
+class Subscription(BaseModel):
+ id: str
+ user_id: str
+ plan: PlanType
+ status: SubscriptionStatus
+ stripe_subscription_id: Optional[str] = None
+ stripe_customer_id: Optional[str] = None
+ current_period_start: datetime
+ current_period_end: datetime
+ cancel_at_period_end: bool = False
+ created_at: datetime = Field(default_factory=datetime.utcnow)
+
+
+class UsageRecord(BaseModel):
+ id: str
+ user_id: str
+ document_name: str
+ document_type: str # excel, word, pptx
+ pages_count: int
+ source_lang: str
+ target_lang: str
+ provider: str
+ processing_time_seconds: float
+ credits_used: int
+ created_at: datetime = Field(default_factory=datetime.utcnow)
+
+
+class CreditPurchase(BaseModel):
+ """For buying extra credits (pay-per-use)"""
+ id: str
+ user_id: str
+ credits_amount: int
+ price_paid: float # in cents
+ stripe_payment_id: str
+ created_at: datetime = Field(default_factory=datetime.utcnow)
+
+
+# Credit packages for purchase
+CREDIT_PACKAGES = [
+ {"credits": 50, "price": 5.00, "price_per_credit": 0.10, "stripe_price_id": "price_credits_50"},
+ {"credits": 100, "price": 9.00, "price_per_credit": 0.09, "stripe_price_id": "price_credits_100", "popular": True},
+ {"credits": 250, "price": 20.00, "price_per_credit": 0.08, "stripe_price_id": "price_credits_250"},
+ {"credits": 500, "price": 35.00, "price_per_credit": 0.07, "stripe_price_id": "price_credits_500"},
+ {"credits": 1000, "price": 60.00, "price_per_credit": 0.06, "stripe_price_id": "price_credits_1000"},
+]
diff --git a/requirements.txt b/requirements.txt
index d65dd6b..1f08493 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -7,6 +7,7 @@ python-pptx==0.6.23
deep-translator==1.11.4
python-dotenv==1.0.0
pydantic==2.5.3
+pydantic[email]==2.5.3
aiofiles==23.2.1
Pillow==10.2.0
matplotlib==3.8.2
@@ -18,3 +19,9 @@ openai>=1.0.0
# SaaS robustness dependencies
psutil==5.9.8
python-magic-bin==0.4.14 # For Windows, use python-magic on Linux
+
+# Authentication & Payments
+PyJWT==2.8.0
+passlib[bcrypt]==1.7.4
+stripe==7.0.0
+
diff --git a/routes/__init__.py b/routes/__init__.py
new file mode 100644
index 0000000..d212dab
--- /dev/null
+++ b/routes/__init__.py
@@ -0,0 +1 @@
+# Routes package
diff --git a/routes/auth_routes.py b/routes/auth_routes.py
new file mode 100644
index 0000000..eaa1941
--- /dev/null
+++ b/routes/auth_routes.py
@@ -0,0 +1,281 @@
+"""
+Authentication and User API routes
+"""
+from fastapi import APIRouter, HTTPException, Depends, Header, Request
+from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
+from pydantic import BaseModel, EmailStr
+from typing import Optional, Dict, Any
+
+from models.subscription import UserCreate, UserLogin, UserResponse, PlanType, PLANS, CREDIT_PACKAGES
+from services.auth_service import (
+ create_user, authenticate_user, get_user_by_id,
+ create_access_token, create_refresh_token, verify_token,
+ check_usage_limits, update_user
+)
+from services.payment_service import (
+ create_checkout_session, create_credits_checkout,
+ cancel_subscription, get_billing_portal_url,
+ handle_webhook, is_stripe_configured
+)
+
+
+router = APIRouter(prefix="/api/auth", tags=["Authentication"])
+security = HTTPBearer(auto_error=False)
+
+
+# Request/Response models
+class TokenResponse(BaseModel):
+ access_token: str
+ refresh_token: str
+ token_type: str = "bearer"
+ user: UserResponse
+
+
+class RefreshRequest(BaseModel):
+ refresh_token: str
+
+
+class CheckoutRequest(BaseModel):
+ plan: PlanType
+ billing_period: str = "monthly"
+
+
+class CreditsCheckoutRequest(BaseModel):
+ package_index: int
+
+
+# Dependency to get current user
+async def get_current_user(credentials: Optional[HTTPAuthorizationCredentials] = Depends(security)):
+ if not credentials:
+ return None
+
+ payload = verify_token(credentials.credentials)
+ if not payload:
+ return None
+
+ user = get_user_by_id(payload.get("sub"))
+ return user
+
+
+async def require_user(credentials: HTTPAuthorizationCredentials = Depends(security)):
+ if not credentials:
+ raise HTTPException(status_code=401, detail="Not authenticated")
+
+ payload = verify_token(credentials.credentials)
+ if not payload:
+ raise HTTPException(status_code=401, detail="Invalid or expired token")
+
+ user = get_user_by_id(payload.get("sub"))
+ if not user:
+ raise HTTPException(status_code=401, detail="User not found")
+
+ return user
+
+
+def user_to_response(user) -> UserResponse:
+ """Convert User to UserResponse with plan limits"""
+ plan_limits = PLANS[user.plan]
+ return UserResponse(
+ id=user.id,
+ email=user.email,
+ name=user.name,
+ avatar_url=user.avatar_url,
+ plan=user.plan,
+ subscription_status=user.subscription_status,
+ docs_translated_this_month=user.docs_translated_this_month,
+ pages_translated_this_month=user.pages_translated_this_month,
+ api_calls_this_month=user.api_calls_this_month,
+ extra_credits=user.extra_credits,
+ created_at=user.created_at,
+ plan_limits={
+ "docs_per_month": plan_limits["docs_per_month"],
+ "max_pages_per_doc": plan_limits["max_pages_per_doc"],
+ "max_file_size_mb": plan_limits["max_file_size_mb"],
+ "providers": plan_limits["providers"],
+ "features": plan_limits["features"],
+ "api_access": plan_limits.get("api_access", False),
+ }
+ )
+
+
+# Auth endpoints
+@router.post("/register", response_model=TokenResponse)
+async def register(user_create: UserCreate):
+ """Register a new user"""
+ try:
+ user = create_user(user_create)
+ except ValueError as e:
+ raise HTTPException(status_code=400, detail=str(e))
+
+ access_token = create_access_token(user.id)
+ refresh_token = create_refresh_token(user.id)
+
+ return TokenResponse(
+ access_token=access_token,
+ refresh_token=refresh_token,
+ user=user_to_response(user)
+ )
+
+
+@router.post("/login", response_model=TokenResponse)
+async def login(credentials: UserLogin):
+ """Login with email and password"""
+ user = authenticate_user(credentials.email, credentials.password)
+ if not user:
+ raise HTTPException(status_code=401, detail="Invalid email or password")
+
+ access_token = create_access_token(user.id)
+ refresh_token = create_refresh_token(user.id)
+
+ return TokenResponse(
+ access_token=access_token,
+ refresh_token=refresh_token,
+ user=user_to_response(user)
+ )
+
+
+@router.post("/refresh", response_model=TokenResponse)
+async def refresh_tokens(request: RefreshRequest):
+ """Refresh access token"""
+ payload = verify_token(request.refresh_token)
+ if not payload or payload.get("type") != "refresh":
+ raise HTTPException(status_code=401, detail="Invalid refresh token")
+
+ user = get_user_by_id(payload.get("sub"))
+ if not user:
+ raise HTTPException(status_code=401, detail="User not found")
+
+ access_token = create_access_token(user.id)
+ refresh_token = create_refresh_token(user.id)
+
+ return TokenResponse(
+ access_token=access_token,
+ refresh_token=refresh_token,
+ user=user_to_response(user)
+ )
+
+
+@router.get("/me", response_model=UserResponse)
+async def get_me(user=Depends(require_user)):
+ """Get current user info"""
+ return user_to_response(user)
+
+
+@router.get("/usage")
+async def get_usage(user=Depends(require_user)):
+ """Get current usage and limits"""
+ return check_usage_limits(user)
+
+
+@router.put("/settings")
+async def update_settings(settings: Dict[str, Any], user=Depends(require_user)):
+ """Update user settings"""
+ allowed_fields = [
+ "default_source_lang", "default_target_lang", "default_provider",
+ "ollama_endpoint", "ollama_model", "name"
+ ]
+
+ updates = {k: v for k, v in settings.items() if k in allowed_fields}
+ updated_user = update_user(user.id, updates)
+
+ if not updated_user:
+ raise HTTPException(status_code=400, detail="Failed to update settings")
+
+ return user_to_response(updated_user)
+
+
+# Plans endpoint (public)
+@router.get("/plans")
+async def get_plans():
+ """Get all available plans"""
+ plans = []
+ for plan_type, config in PLANS.items():
+ plans.append({
+ "id": plan_type.value,
+ "name": config["name"],
+ "price_monthly": config["price_monthly"],
+ "price_yearly": config["price_yearly"],
+ "features": config["features"],
+ "docs_per_month": config["docs_per_month"],
+ "max_pages_per_doc": config["max_pages_per_doc"],
+ "max_file_size_mb": config["max_file_size_mb"],
+ "providers": config["providers"],
+ "api_access": config.get("api_access", False),
+ "popular": plan_type == PlanType.PRO,
+ })
+ return {"plans": plans, "credit_packages": CREDIT_PACKAGES}
+
+
+# Payment endpoints
+@router.post("/checkout/subscription")
+async def checkout_subscription(request: CheckoutRequest, user=Depends(require_user)):
+ """Create Stripe checkout session for subscription"""
+ if not is_stripe_configured():
+ # Demo mode - just upgrade the user
+ update_user(user.id, {"plan": request.plan.value})
+ return {"demo_mode": True, "message": "Upgraded in demo mode", "plan": request.plan.value}
+
+ result = await create_checkout_session(
+ user.id,
+ request.plan,
+ request.billing_period
+ )
+
+ if "error" in result:
+ raise HTTPException(status_code=400, detail=result["error"])
+
+ return result
+
+
+@router.post("/checkout/credits")
+async def checkout_credits(request: CreditsCheckoutRequest, user=Depends(require_user)):
+ """Create Stripe checkout session for credits"""
+ if not is_stripe_configured():
+ # Demo mode - add credits directly
+ from services.auth_service import add_credits
+ credits = CREDIT_PACKAGES[request.package_index]["credits"]
+ add_credits(user.id, credits)
+ return {"demo_mode": True, "message": f"Added {credits} credits in demo mode"}
+
+ result = await create_credits_checkout(user.id, request.package_index)
+
+ if "error" in result:
+ raise HTTPException(status_code=400, detail=result["error"])
+
+ return result
+
+
+@router.post("/subscription/cancel")
+async def cancel_user_subscription(user=Depends(require_user)):
+ """Cancel subscription"""
+ result = await cancel_subscription(user.id)
+
+ if "error" in result:
+ raise HTTPException(status_code=400, detail=result["error"])
+
+ return result
+
+
+@router.get("/billing-portal")
+async def get_billing_portal(user=Depends(require_user)):
+ """Get Stripe billing portal URL"""
+ url = await get_billing_portal_url(user.id)
+
+ if not url:
+ raise HTTPException(status_code=400, detail="Billing portal not available")
+
+ return {"url": url}
+
+
+# Stripe webhook
+@router.post("/webhook/stripe")
+async def stripe_webhook(request: Request, stripe_signature: str = Header(None)):
+ """Handle Stripe webhooks"""
+ payload = await request.body()
+
+ result = await handle_webhook(payload, stripe_signature or "")
+
+ if "error" in result:
+ raise HTTPException(status_code=400, detail=result["error"])
+
+ return result
diff --git a/services/auth_service.py b/services/auth_service.py
new file mode 100644
index 0000000..68a3ed2
--- /dev/null
+++ b/services/auth_service.py
@@ -0,0 +1,262 @@
+"""
+Authentication service with JWT tokens and password hashing
+"""
+import os
+import secrets
+import hashlib
+from datetime import datetime, timedelta
+from typing import Optional, Dict, Any
+import json
+from pathlib import Path
+
+# Try to import optional dependencies
+try:
+ import jwt
+ JWT_AVAILABLE = True
+except ImportError:
+ JWT_AVAILABLE = False
+
+try:
+ from passlib.context import CryptContext
+ pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
+ PASSLIB_AVAILABLE = True
+except ImportError:
+ PASSLIB_AVAILABLE = False
+
+from models.subscription import User, UserCreate, PlanType, SubscriptionStatus, PLANS
+
+
+# Configuration
+SECRET_KEY = os.getenv("JWT_SECRET_KEY", secrets.token_urlsafe(32))
+ALGORITHM = "HS256"
+ACCESS_TOKEN_EXPIRE_HOURS = 24
+REFRESH_TOKEN_EXPIRE_DAYS = 30
+
+# Simple file-based storage (replace with database in production)
+USERS_FILE = Path("data/users.json")
+USERS_FILE.parent.mkdir(exist_ok=True)
+
+
+def hash_password(password: str) -> str:
+ """Hash a password using bcrypt or fallback to SHA256"""
+ if PASSLIB_AVAILABLE:
+ return pwd_context.hash(password)
+ else:
+ # Fallback to SHA256 with salt
+ salt = secrets.token_hex(16)
+ hashed = hashlib.sha256(f"{salt}{password}".encode()).hexdigest()
+ return f"sha256${salt}${hashed}"
+
+
+def verify_password(plain_password: str, hashed_password: str) -> bool:
+ """Verify a password against its hash"""
+ if PASSLIB_AVAILABLE and not hashed_password.startswith("sha256$"):
+ return pwd_context.verify(plain_password, hashed_password)
+ else:
+ # Fallback SHA256 verification
+ parts = hashed_password.split("$")
+ if len(parts) == 3 and parts[0] == "sha256":
+ salt = parts[1]
+ expected_hash = parts[2]
+ actual_hash = hashlib.sha256(f"{salt}{plain_password}".encode()).hexdigest()
+ return secrets.compare_digest(actual_hash, expected_hash)
+ return False
+
+
+def create_access_token(user_id: str, expires_delta: Optional[timedelta] = None) -> str:
+ """Create a JWT access token"""
+ if not JWT_AVAILABLE:
+ # Fallback to simple token
+ token_data = {
+ "user_id": user_id,
+ "exp": (datetime.utcnow() + (expires_delta or timedelta(hours=ACCESS_TOKEN_EXPIRE_HOURS))).isoformat()
+ }
+ import base64
+ return base64.urlsafe_b64encode(json.dumps(token_data).encode()).decode()
+
+ expire = datetime.utcnow() + (expires_delta or timedelta(hours=ACCESS_TOKEN_EXPIRE_HOURS))
+ to_encode = {"sub": user_id, "exp": expire, "type": "access"}
+ return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
+
+
+def create_refresh_token(user_id: str) -> str:
+ """Create a JWT refresh token"""
+ if not JWT_AVAILABLE:
+ token_data = {
+ "user_id": user_id,
+ "exp": (datetime.utcnow() + timedelta(days=REFRESH_TOKEN_EXPIRE_DAYS)).isoformat()
+ }
+ import base64
+ return base64.urlsafe_b64encode(json.dumps(token_data).encode()).decode()
+
+ expire = datetime.utcnow() + timedelta(days=REFRESH_TOKEN_EXPIRE_DAYS)
+ to_encode = {"sub": user_id, "exp": expire, "type": "refresh"}
+ return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
+
+
+def verify_token(token: str) -> Optional[Dict[str, Any]]:
+ """Verify a JWT token and return payload"""
+ if not JWT_AVAILABLE:
+ try:
+ import base64
+ data = json.loads(base64.urlsafe_b64decode(token.encode()).decode())
+ exp = datetime.fromisoformat(data["exp"])
+ if exp < datetime.utcnow():
+ return None
+ return {"sub": data["user_id"]}
+ except:
+ return None
+
+ try:
+ payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
+ return payload
+ except jwt.ExpiredSignatureError:
+ return None
+ except jwt.JWTError:
+ return None
+
+
+def load_users() -> Dict[str, Dict]:
+ """Load users from file storage"""
+ if USERS_FILE.exists():
+ try:
+ with open(USERS_FILE, 'r') as f:
+ return json.load(f)
+ except:
+ return {}
+ return {}
+
+
+def save_users(users: Dict[str, Dict]):
+ """Save users to file storage"""
+ with open(USERS_FILE, 'w') as f:
+ json.dump(users, f, indent=2, default=str)
+
+
+def get_user_by_email(email: str) -> Optional[User]:
+ """Get a user by email"""
+ users = load_users()
+ for user_data in users.values():
+ if user_data.get("email", "").lower() == email.lower():
+ return User(**user_data)
+ return None
+
+
+def get_user_by_id(user_id: str) -> Optional[User]:
+ """Get a user by ID"""
+ users = load_users()
+ if user_id in users:
+ return User(**users[user_id])
+ return None
+
+
+def create_user(user_create: UserCreate) -> User:
+ """Create a new user"""
+ users = load_users()
+
+ # Check if email exists
+ if get_user_by_email(user_create.email):
+ raise ValueError("Email already registered")
+
+ # Generate user ID
+ user_id = secrets.token_urlsafe(16)
+
+ # Create user
+ user = User(
+ id=user_id,
+ email=user_create.email,
+ name=user_create.name,
+ password_hash=hash_password(user_create.password),
+ plan=PlanType.FREE,
+ subscription_status=SubscriptionStatus.ACTIVE,
+ )
+
+ # Save to storage
+ users[user_id] = user.model_dump()
+ save_users(users)
+
+ return user
+
+
+def authenticate_user(email: str, password: str) -> Optional[User]:
+ """Authenticate a user with email and password"""
+ user = get_user_by_email(email)
+ if not user:
+ return None
+ if not verify_password(password, user.password_hash):
+ return None
+ return user
+
+
+def update_user(user_id: str, updates: Dict[str, Any]) -> Optional[User]:
+ """Update a user's data"""
+ users = load_users()
+ if user_id not in users:
+ return None
+
+ users[user_id].update(updates)
+ users[user_id]["updated_at"] = datetime.utcnow().isoformat()
+ save_users(users)
+
+ return User(**users[user_id])
+
+
+def check_usage_limits(user: User) -> Dict[str, Any]:
+ """Check if user has exceeded their plan limits"""
+ plan = PLANS[user.plan]
+
+ # Reset usage if it's a new month
+ now = datetime.utcnow()
+ if user.usage_reset_date.month != now.month or user.usage_reset_date.year != now.year:
+ update_user(user.id, {
+ "docs_translated_this_month": 0,
+ "pages_translated_this_month": 0,
+ "api_calls_this_month": 0,
+ "usage_reset_date": now.isoformat()
+ })
+ user.docs_translated_this_month = 0
+ user.pages_translated_this_month = 0
+ user.api_calls_this_month = 0
+
+ docs_limit = plan["docs_per_month"]
+ docs_remaining = max(0, docs_limit - user.docs_translated_this_month) if docs_limit > 0 else -1
+
+ return {
+ "can_translate": docs_remaining != 0 or user.extra_credits > 0,
+ "docs_used": user.docs_translated_this_month,
+ "docs_limit": docs_limit,
+ "docs_remaining": docs_remaining,
+ "pages_used": user.pages_translated_this_month,
+ "extra_credits": user.extra_credits,
+ "max_pages_per_doc": plan["max_pages_per_doc"],
+ "max_file_size_mb": plan["max_file_size_mb"],
+ "allowed_providers": plan["providers"],
+ }
+
+
+def record_usage(user_id: str, pages_count: int, use_credits: bool = False) -> bool:
+ """Record document translation usage"""
+ user = get_user_by_id(user_id)
+ if not user:
+ return False
+
+ updates = {
+ "docs_translated_this_month": user.docs_translated_this_month + 1,
+ "pages_translated_this_month": user.pages_translated_this_month + pages_count,
+ }
+
+ if use_credits:
+ updates["extra_credits"] = max(0, user.extra_credits - pages_count)
+
+ update_user(user_id, updates)
+ return True
+
+
+def add_credits(user_id: str, credits: int) -> bool:
+ """Add credits to a user's account"""
+ user = get_user_by_id(user_id)
+ if not user:
+ return False
+
+ update_user(user_id, {"extra_credits": user.extra_credits + credits})
+ return True
diff --git a/services/payment_service.py b/services/payment_service.py
new file mode 100644
index 0000000..01bfaae
--- /dev/null
+++ b/services/payment_service.py
@@ -0,0 +1,298 @@
+"""
+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