diff --git a/.env.example b/.env.example index 1a0f3fd..bf4fb7b 100644 --- a/.env.example +++ b/.env.example @@ -73,6 +73,19 @@ ADMIN_PASSWORD=changeme123 # Token secret for session management (auto-generated if not set) # ADMIN_TOKEN_SECRET= +# ============== User Authentication ============== +# JWT secret key (auto-generated if not set) +# JWT_SECRET_KEY= + +# Frontend URL for redirects +FRONTEND_URL=http://localhost:3000 + +# ============== Stripe Payments ============== +# Get your keys from https://dashboard.stripe.com/apikeys +STRIPE_PUBLISHABLE_KEY=pk_test_... +STRIPE_SECRET_KEY=sk_test_... +STRIPE_WEBHOOK_SECRET=whsec_... + # ============== Monitoring ============== # Log level: DEBUG, INFO, WARNING, ERROR LOG_LEVEL=INFO diff --git a/.gitignore b/.gitignore index 25cd6b6..58543d1 100644 --- a/.gitignore +++ b/.gitignore @@ -40,9 +40,11 @@ outputs/ temp/ translated_files/ translated_test.* +data/ # Logs *.log +logs/ # UV / UV lock .venv/ diff --git a/frontend/src/app/auth/login/page.tsx b/frontend/src/app/auth/login/page.tsx new file mode 100644 index 0000000..72696ac --- /dev/null +++ b/frontend/src/app/auth/login/page.tsx @@ -0,0 +1,169 @@ +"use client"; + +import { useState } from "react"; +import { useRouter, useSearchParams } from "next/navigation"; +import Link from "next/link"; +import { Eye, EyeOff, Mail, Lock, ArrowRight, Loader2 } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; + +export default function LoginPage() { + const router = useRouter(); + const searchParams = useSearchParams(); + const redirect = searchParams.get("redirect") || "/"; + + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [showPassword, setShowPassword] = useState(false); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(""); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(""); + setLoading(true); + + try { + const res = await fetch("http://localhost:8000/api/auth/login", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email, password }), + }); + + const data = await res.json(); + + if (!res.ok) { + throw new Error(data.detail || "Login failed"); + } + + // Store tokens + localStorage.setItem("token", data.access_token); + localStorage.setItem("refresh_token", data.refresh_token); + localStorage.setItem("user", JSON.stringify(data.user)); + + // Redirect + router.push(redirect); + } catch (err: any) { + setError(err.message || "Login failed"); + } finally { + setLoading(false); + } + }; + + return ( +
+
+ {/* Logo */} +
+ +
+ 文A +
+ Translate Co. + +
+ + {/* Card */} +
+
+

Welcome back

+

Sign in to continue translating

+
+ + {error && ( +
+ {error} +
+ )} + +
+
+ +
+ + setEmail(e.target.value)} + required + className="pl-10 bg-zinc-800 border-zinc-700 text-white placeholder:text-zinc-500" + /> +
+
+ +
+
+ + + Forgot password? + +
+
+ + setPassword(e.target.value)} + required + className="pl-10 pr-10 bg-zinc-800 border-zinc-700 text-white placeholder:text-zinc-500" + /> + +
+
+ + +
+ +
+ Don't have an account?{" "} + + Sign up for free + +
+
+ + {/* Features reminder */} +
+

Start with our free plan:

+
+ {["3 docs/day", "10 pages/doc", "Ollama support"].map((feature) => ( + + {feature} + + ))} +
+
+
+
+ ); +} diff --git a/frontend/src/app/auth/register/page.tsx b/frontend/src/app/auth/register/page.tsx new file mode 100644 index 0000000..697adb6 --- /dev/null +++ b/frontend/src/app/auth/register/page.tsx @@ -0,0 +1,206 @@ +"use client"; + +import { useState } from "react"; +import { useRouter, useSearchParams } from "next/navigation"; +import Link from "next/link"; +import { Eye, EyeOff, Mail, Lock, User, ArrowRight, Loader2 } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; + +export default function RegisterPage() { + const router = useRouter(); + const searchParams = useSearchParams(); + const redirect = searchParams.get("redirect") || "/"; + + const [name, setName] = useState(""); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [confirmPassword, setConfirmPassword] = useState(""); + const [showPassword, setShowPassword] = useState(false); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(""); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(""); + + if (password !== confirmPassword) { + setError("Passwords do not match"); + return; + } + + if (password.length < 8) { + setError("Password must be at least 8 characters"); + return; + } + + setLoading(true); + + try { + const res = await fetch("http://localhost:8000/api/auth/register", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name, email, password }), + }); + + const data = await res.json(); + + if (!res.ok) { + throw new Error(data.detail || "Registration failed"); + } + + // Store tokens + localStorage.setItem("token", data.access_token); + localStorage.setItem("refresh_token", data.refresh_token); + localStorage.setItem("user", JSON.stringify(data.user)); + + // Redirect + router.push(redirect); + } catch (err: any) { + setError(err.message || "Registration failed"); + } finally { + setLoading(false); + } + }; + + return ( +
+
+ {/* Logo */} +
+ +
+ 文A +
+ Translate Co. + +
+ + {/* Card */} +
+
+

Create an account

+

Start translating documents for free

+
+ + {error && ( +
+ {error} +
+ )} + +
+
+ +
+ + setName(e.target.value)} + required + className="pl-10 bg-zinc-800 border-zinc-700 text-white placeholder:text-zinc-500" + /> +
+
+ +
+ +
+ + setEmail(e.target.value)} + required + className="pl-10 bg-zinc-800 border-zinc-700 text-white placeholder:text-zinc-500" + /> +
+
+ +
+ +
+ + setPassword(e.target.value)} + required + minLength={8} + className="pl-10 pr-10 bg-zinc-800 border-zinc-700 text-white placeholder:text-zinc-500" + /> + +
+
+ +
+ +
+ + setConfirmPassword(e.target.value)} + required + className="pl-10 bg-zinc-800 border-zinc-700 text-white placeholder:text-zinc-500" + /> +
+
+ + +
+ +
+ Already have an account?{" "} + + Sign in + +
+ +
+ By creating an account, you agree to our{" "} + + Terms of Service + {" "} + and{" "} + + Privacy Policy + +
+
+
+
+ ); +} diff --git a/frontend/src/app/dashboard/page.tsx b/frontend/src/app/dashboard/page.tsx new file mode 100644 index 0000000..2aeba82 --- /dev/null +++ b/frontend/src/app/dashboard/page.tsx @@ -0,0 +1,363 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { useRouter } from "next/navigation"; +import Link from "next/link"; +import { + FileText, + CreditCard, + Settings, + LogOut, + ChevronRight, + Zap, + TrendingUp, + Clock, + Check, + ExternalLink, + Crown, +} from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { Progress } from "@/components/ui/progress"; +import { cn } from "@/lib/utils"; + +interface User { + id: string; + email: string; + name: string; + plan: string; + subscription_status: string; + docs_translated_this_month: number; + pages_translated_this_month: number; + extra_credits: number; + plan_limits: { + docs_per_month: number; + max_pages_per_doc: number; + features: string[]; + providers: string[]; + }; +} + +interface UsageStats { + docs_used: number; + docs_limit: number; + docs_remaining: number; + pages_used: number; + extra_credits: number; + max_pages_per_doc: number; + allowed_providers: string[]; +} + +export default function DashboardPage() { + const router = useRouter(); + const [user, setUser] = useState(null); + const [usage, setUsage] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + const token = localStorage.getItem("token"); + if (!token) { + router.push("/auth/login?redirect=/dashboard"); + return; + } + + fetchUserData(token); + }, [router]); + + const fetchUserData = async (token: string) => { + try { + const [userRes, usageRes] = await Promise.all([ + fetch("http://localhost:8000/api/auth/me", { + headers: { Authorization: `Bearer ${token}` }, + }), + fetch("http://localhost:8000/api/auth/usage", { + headers: { Authorization: `Bearer ${token}` }, + }), + ]); + + if (!userRes.ok) { + throw new Error("Session expired"); + } + + const userData = await userRes.json(); + const usageData = await usageRes.json(); + + setUser(userData); + setUsage(usageData); + } catch (error) { + localStorage.removeItem("token"); + localStorage.removeItem("user"); + router.push("/auth/login?redirect=/dashboard"); + } finally { + setLoading(false); + } + }; + + const handleLogout = () => { + localStorage.removeItem("token"); + localStorage.removeItem("refresh_token"); + localStorage.removeItem("user"); + router.push("/"); + }; + + const handleUpgrade = () => { + router.push("/pricing"); + }; + + const handleManageBilling = async () => { + const token = localStorage.getItem("token"); + try { + const res = await fetch("http://localhost:8000/api/auth/billing-portal", { + headers: { Authorization: `Bearer ${token}` }, + }); + const data = await res.json(); + if (data.url) { + window.open(data.url, "_blank"); + } + } catch (error) { + console.error("Failed to open billing portal"); + } + }; + + if (loading) { + return ( +
+
+
+ ); + } + + if (!user || !usage) { + return null; + } + + const docsPercentage = usage.docs_limit > 0 + ? Math.min(100, (usage.docs_used / usage.docs_limit) * 100) + : 0; + + const planColors: Record = { + free: "bg-zinc-500", + starter: "bg-blue-500", + pro: "bg-teal-500", + business: "bg-purple-500", + enterprise: "bg-amber-500", + }; + + return ( +
+ {/* Header */} +
+
+
+ +
+ 文A +
+ Translate Co. + + +
+ + + + +
+
+ {user.name.charAt(0).toUpperCase()} +
+ +
+
+
+
+
+ +
+ {/* Welcome Section */} +
+

Welcome back, {user.name.split(" ")[0]}!

+

Here's an overview of your translation usage

+
+ + {/* Stats Grid */} +
+ {/* Current Plan */} +
+
+ Current Plan + + {user.plan.charAt(0).toUpperCase() + user.plan.slice(1)} + +
+
+ + {user.plan} +
+ {user.plan !== "enterprise" && ( + + )} +
+ + {/* Documents Used */} +
+
+ Documents This Month + +
+
+ {usage.docs_used} / {usage.docs_limit === -1 ? "∞" : usage.docs_limit} +
+ +

+ {usage.docs_remaining === -1 + ? "Unlimited" + : `${usage.docs_remaining} remaining`} +

+
+ + {/* Pages Translated */} +
+
+ Pages Translated + +
+
+ {usage.pages_used} +
+

+ Max {usage.max_pages_per_doc === -1 ? "unlimited" : usage.max_pages_per_doc} pages/doc +

+
+ + {/* Extra Credits */} +
+
+ Extra Credits + +
+
+ {usage.extra_credits} +
+ + + +
+
+ + {/* Features & Actions */} +
+ {/* Available Features */} +
+

Your Plan Features

+
    + {user.plan_limits.features.map((feature, idx) => ( +
  • + + {feature} +
  • + ))} +
+
+ + {/* Quick Actions */} +
+

Quick Actions

+
+ + + + + + + + + {user.plan !== "free" && ( + + )} + + + + +
+
+
+ + {/* Available Providers */} +
+

Available Translation Providers

+
+ {["ollama", "google", "deepl", "openai", "libre", "azure"].map((provider) => { + const isAvailable = usage.allowed_providers.includes(provider); + return ( + + {isAvailable && } + {provider} + + ); + })} +
+ {user.plan === "free" && ( +

+ + Upgrade your plan + {" "} + to access more translation providers including Google, DeepL, and OpenAI. +

+ )} +
+
+
+ ); +} diff --git a/frontend/src/app/ollama-setup/page.tsx b/frontend/src/app/ollama-setup/page.tsx new file mode 100644 index 0000000..e7761e0 --- /dev/null +++ b/frontend/src/app/ollama-setup/page.tsx @@ -0,0 +1,364 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { + Server, + Check, + AlertCircle, + Loader2, + ExternalLink, + Copy, + Terminal, + Cpu, + HardDrive +} from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Badge } from "@/components/ui/badge"; +import { cn } from "@/lib/utils"; + +interface OllamaModel { + name: string; + size: string; + quantization: string; +} + +const recommendedModels: OllamaModel[] = [ + { name: "llama3.2:3b", size: "2 GB", quantization: "Q4_0" }, + { name: "mistral:7b", size: "4.1 GB", quantization: "Q4_0" }, + { name: "qwen2.5:7b", size: "4.7 GB", quantization: "Q4_K_M" }, + { name: "llama3.1:8b", size: "4.7 GB", quantization: "Q4_0" }, + { name: "gemma2:9b", size: "5.4 GB", quantization: "Q4_0" }, +]; + +export default function OllamaSetupPage() { + const [endpoint, setEndpoint] = useState("http://localhost:11434"); + const [selectedModel, setSelectedModel] = useState(""); + const [availableModels, setAvailableModels] = useState([]); + const [testing, setTesting] = useState(false); + const [connectionStatus, setConnectionStatus] = useState<"idle" | "success" | "error">("idle"); + const [errorMessage, setErrorMessage] = useState(""); + const [copied, setCopied] = useState(null); + + const testConnection = async () => { + setTesting(true); + setConnectionStatus("idle"); + setErrorMessage(""); + + try { + // Test connection to Ollama + const res = await fetch(`${endpoint}/api/tags`); + if (!res.ok) throw new Error("Failed to connect to Ollama"); + + const data = await res.json(); + const models = data.models?.map((m: any) => m.name) || []; + setAvailableModels(models); + setConnectionStatus("success"); + + // Auto-select first model if available + if (models.length > 0 && !selectedModel) { + setSelectedModel(models[0]); + } + } catch (error: any) { + setConnectionStatus("error"); + setErrorMessage(error.message || "Failed to connect to Ollama"); + } finally { + setTesting(false); + } + }; + + const copyToClipboard = (text: string, id: string) => { + navigator.clipboard.writeText(text); + setCopied(id); + setTimeout(() => setCopied(null), 2000); + }; + + const saveSettings = () => { + // Save to localStorage or user settings + const settings = { ollamaEndpoint: endpoint, ollamaModel: selectedModel }; + localStorage.setItem("ollamaSettings", JSON.stringify(settings)); + + // Also save to user account if logged in + const token = localStorage.getItem("token"); + if (token) { + fetch("http://localhost:8000/api/auth/settings", { + method: "PUT", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ + ollama_endpoint: endpoint, + ollama_model: selectedModel, + }), + }); + } + + alert("Settings saved successfully!"); + }; + + return ( +
+
+ {/* Header */} +
+ + Self-Hosted + +

+ Configure Your Ollama Server +

+

+ Connect your own Ollama instance for unlimited, free translations using local AI models. +

+
+ + {/* What is Ollama */} +
+

+ + What is Ollama? +

+

+ Ollama is a free, open-source tool that lets you run large language models locally on your computer. + With Ollama, you can translate documents without sending data to external servers, ensuring complete privacy. +

+ +
+ + {/* Installation Guide */} +
+

+ + Quick Installation Guide +

+ +
+ {/* Step 1 */} +
+

1. Install Ollama

+
+
+
+ macOS / Linux: + curl -fsSL https://ollama.ai/install.sh | sh +
+ +
+

+ Windows: Download from ollama.ai/download +

+
+
+ + {/* Step 2 */} +
+

2. Pull a Translation Model

+
+ ollama pull llama3.2:3b + +
+
+ + {/* Step 3 */} +
+

3. Start Ollama Server

+
+ ollama serve + +
+

+ On macOS/Windows with the desktop app, Ollama runs automatically in the background. +

+
+
+
+ + {/* Recommended Models */} +
+

+ + Recommended Models for Translation +

+
+ {recommendedModels.map((model) => ( +
+
+ {model.name} +
+ + + {model.size} + +
+
+ +
+ ))} +
+

+ 💡 Tip: For best results with limited RAM (8GB), use llama3.2:3b. + With 16GB+ RAM, try mistral:7b or larger. +

+
+ + {/* Configuration */} +
+

Configure Connection

+ +
+
+ +
+ setEndpoint(e.target.value)} + placeholder="http://localhost:11434" + className="bg-zinc-800 border-zinc-700 text-white" + /> + +
+
+ + {/* Connection Status */} + {connectionStatus === "success" && ( +
+ + Connected successfully! Found {availableModels.length} model(s). +
+ )} + + {connectionStatus === "error" && ( +
+ + {errorMessage} +
+ )} + + {/* Model Selection */} + {availableModels.length > 0 && ( +
+ +
+ {availableModels.map((model) => ( + + ))} +
+
+ )} + + {/* Save Button */} + {connectionStatus === "success" && selectedModel && ( + + )} +
+
+ + {/* Benefits */} +
+

Why Self-Host?

+
+
+
🔒
+

Complete Privacy

+

Your documents never leave your computer

+
+
+
♾️
+

Unlimited Usage

+

No monthly limits or quotas

+
+
+
💰
+

Free Forever

+

No subscription or API costs

+
+
+
+
+
+ ); +} diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index 3197ab2..051565d 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -5,6 +5,12 @@ import { useTranslationStore } from "@/lib/store"; import { Badge } from "@/components/ui/badge"; import { Settings } from "lucide-react"; import Link from "next/link"; +import { + LandingHero, + FeaturesSection, + PricingPreview, + SelfHostCTA +} from "@/components/landing-sections"; export default function Home() { const { settings } = useTranslationStore(); @@ -15,35 +21,71 @@ export default function Home() { deepl: "DeepL", libre: "LibreTranslate", webllm: "WebLLM", + openai: "OpenAI", }; return ( -
-
-
-

Translate Documents

-

- Upload and translate Excel, Word, and PowerPoint files while preserving all formatting. -

-
- - {/* Current Configuration Badge */} - - -
- - {providerNames[settings.defaultProvider]} - - {settings.defaultProvider === "ollama" && settings.ollamaModel && ( - - {settings.ollamaModel} - - )} +
+ {/* Hero Section */} + + + {/* Upload Section */} +
+
+
+
+

Translate Your Document

+

+ Upload and translate Excel, Word, and PowerPoint files while preserving all formatting. +

+
+ + {/* Current Configuration Badge */} + + +
+ + {providerNames[settings.defaultProvider]} + + {settings.defaultProvider === "ollama" && settings.ollamaModel && ( + + {settings.ollamaModel} + + )} +
+
- + + +
- - + + {/* Features Section */} + + + {/* Pricing Preview */} + + + {/* Self-Host CTA */} + + + {/* Footer */} +
+
+
+
+ 文A +
+ © 2024 Translate Co. All rights reserved. +
+
+ Pricing + Self-Host + Terms + Privacy +
+
+
); } diff --git a/frontend/src/app/pricing/page.tsx b/frontend/src/app/pricing/page.tsx new file mode 100644 index 0000000..6940ad7 --- /dev/null +++ b/frontend/src/app/pricing/page.tsx @@ -0,0 +1,421 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { Check, Zap, Building2, Crown, Sparkles } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { cn } from "@/lib/utils"; + +interface Plan { + id: string; + name: string; + price_monthly: number; + price_yearly: number; + features: string[]; + docs_per_month: number; + max_pages_per_doc: number; + providers: string[]; + popular?: boolean; +} + +interface CreditPackage { + credits: number; + price: number; + price_per_credit: number; + popular?: boolean; +} + +const planIcons: Record = { + free: Sparkles, + starter: Zap, + pro: Crown, + business: Building2, + enterprise: Building2, +}; + +export default function PricingPage() { + const [isYearly, setIsYearly] = useState(false); + const [plans, setPlans] = useState([]); + const [creditPackages, setCreditPackages] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + fetchPlans(); + }, []); + + const fetchPlans = async () => { + try { + const res = await fetch("http://localhost:8000/api/auth/plans"); + const data = await res.json(); + setPlans(data.plans || []); + setCreditPackages(data.credit_packages || []); + } catch (error) { + // Use default plans if API fails + setPlans([ + { + id: "free", + name: "Free", + price_monthly: 0, + price_yearly: 0, + features: [ + "3 documents per day", + "Up to 10 pages per document", + "Ollama (self-hosted) only", + "Basic support via community", + ], + docs_per_month: 3, + max_pages_per_doc: 10, + providers: ["ollama"], + }, + { + id: "starter", + name: "Starter", + price_monthly: 9, + price_yearly: 90, + features: [ + "50 documents per month", + "Up to 50 pages per document", + "Google Translate included", + "LibreTranslate included", + "Email support", + ], + docs_per_month: 50, + max_pages_per_doc: 50, + providers: ["ollama", "google", "libre"], + }, + { + id: "pro", + name: "Pro", + price_monthly: 29, + price_yearly: 290, + 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", + ], + docs_per_month: 200, + max_pages_per_doc: 200, + providers: ["ollama", "google", "deepl", "openai", "libre"], + popular: true, + }, + { + id: "business", + name: "Business", + price_monthly: 79, + price_yearly: 790, + 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)", + ], + docs_per_month: 1000, + max_pages_per_doc: 500, + providers: ["ollama", "google", "deepl", "openai", "libre", "azure"], + }, + { + id: "enterprise", + name: "Enterprise", + price_monthly: -1, + price_yearly: -1, + features: [ + "Unlimited documents", + "Unlimited pages", + "Custom integrations", + "On-premise deployment", + "SLA guarantee", + "24/7 dedicated support", + "Custom AI models", + "White-label option", + ], + docs_per_month: -1, + max_pages_per_doc: -1, + providers: ["all"], + }, + ]); + setCreditPackages([ + { credits: 50, price: 5, price_per_credit: 0.1 }, + { credits: 100, price: 9, price_per_credit: 0.09, popular: true }, + { credits: 250, price: 20, price_per_credit: 0.08 }, + { credits: 500, price: 35, price_per_credit: 0.07 }, + { credits: 1000, price: 60, price_per_credit: 0.06 }, + ]); + } finally { + setLoading(false); + } + }; + + const handleSubscribe = async (planId: string) => { + // Check if user is logged in + const token = localStorage.getItem("token"); + if (!token) { + window.location.href = "/auth/login?redirect=/pricing&plan=" + planId; + return; + } + + // Create checkout session + try { + const res = await fetch("http://localhost:8000/api/auth/checkout/subscription", { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ + plan: planId, + billing_period: isYearly ? "yearly" : "monthly", + }), + }); + + const data = await res.json(); + + if (data.url) { + window.location.href = data.url; + } else if (data.demo_mode) { + alert("Upgraded to " + planId + " (demo mode)"); + window.location.href = "/dashboard"; + } + } catch (error) { + console.error("Checkout error:", error); + } + }; + + return ( +
+
+ {/* Header */} +
+ + Pricing + +

+ Simple, Transparent Pricing +

+

+ Choose the perfect plan for your translation needs. Start free and scale as you grow. +

+ + {/* Billing Toggle */} +
+ + Monthly + + + + Yearly + + Save 17% + + +
+
+ + {/* Plans Grid */} +
+ {plans.slice(0, 4).map((plan) => { + const Icon = planIcons[plan.id] || Sparkles; + const price = isYearly ? plan.price_yearly : plan.price_monthly; + const isEnterprise = plan.id === "enterprise"; + const isPro = plan.popular; + + return ( +
+ {isPro && ( + + Most Popular + + )} + +
+
+ +
+

{plan.name}

+
+ +
+ {isEnterprise || price < 0 ? ( +
Custom
+ ) : ( + <> + + ${isYearly ? Math.round(price / 12) : price} + + /month + {isYearly && price > 0 && ( +
+ ${price} billed yearly +
+ )} + + )} +
+ +
    + {plan.features.map((feature, idx) => ( +
  • + + {feature} +
  • + ))} +
+ + +
+ ); + })} +
+ + {/* Enterprise Section */} + {plans.find((p) => p.id === "enterprise") && ( +
+
+
+

+ Need Enterprise Features? +

+

+ Get unlimited translations, custom integrations, on-premise deployment, + dedicated support, and SLA guarantees. Perfect for large organizations. +

+
+ +
+
+ )} + + {/* Credit Packages */} +
+
+

Need Extra Pages?

+

+ Buy credit packages to translate more pages. Credits never expire. +

+
+ +
+ {creditPackages.map((pkg, idx) => ( +
+ {pkg.popular && ( + + Best Value + + )} +
{pkg.credits}
+
pages
+
${pkg.price}
+
+ ${pkg.price_per_credit.toFixed(2)}/page +
+ +
+ ))} +
+
+ + {/* FAQ Section */} +
+

+ Frequently Asked Questions +

+ +
+ {[ + { + q: "Can I use my own Ollama instance?", + a: "Yes! The Free plan lets you connect your own Ollama server for unlimited translations. You just need to configure your Ollama endpoint in the settings.", + }, + { + q: "What happens if I exceed my monthly limit?", + a: "You can either wait for the next month, upgrade to a higher plan, or purchase credit packages for additional pages.", + }, + { + q: "Can I cancel my subscription anytime?", + a: "Yes, you can cancel anytime. You'll continue to have access until the end of your billing period.", + }, + { + q: "Do credits expire?", + a: "No, purchased credits never expire and can be used anytime.", + }, + { + q: "What file formats are supported?", + a: "We support Microsoft Office documents: Word (.docx), Excel (.xlsx), and PowerPoint (.pptx). The formatting is fully preserved during translation.", + }, + ].map((faq, idx) => ( +
+

{faq.q}

+

{faq.a}

+
+ ))} +
+
+
+
+ ); +} diff --git a/frontend/src/components/landing-sections.tsx b/frontend/src/components/landing-sections.tsx new file mode 100644 index 0000000..9bc30fd --- /dev/null +++ b/frontend/src/components/landing-sections.tsx @@ -0,0 +1,309 @@ +"use client"; + +import { useState, useEffect } from "react"; +import Link from "next/link"; +import { + ArrowRight, + Check, + FileText, + Globe2, + Zap, + Shield, + Server, + Sparkles, + FileSpreadsheet, + Presentation +} from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { cn } from "@/lib/utils"; + +interface User { + name: string; + plan: string; +} + +export function LandingHero() { + const [user, setUser] = useState(null); + + useEffect(() => { + const storedUser = localStorage.getItem("user"); + if (storedUser) { + try { + setUser(JSON.parse(storedUser)); + } catch { + setUser(null); + } + } + }, []); + + return ( +
+ {/* Background gradient */} +
+ + {/* Hero content */} +
+
+ + + AI-Powered Document Translation + + +

+ Translate Documents{" "} + + Instantly + +

+ +

+ Upload Word, Excel, and PowerPoint files. Get perfect translations while preserving + all formatting, styles, and layouts. Powered by AI. +

+ +
+ {user ? ( + + + + ) : ( + <> + + + + + + + + )} +
+ + {/* Supported formats */} +
+
+ + Word (.docx) +
+
+ + Excel (.xlsx) +
+
+ + PowerPoint (.pptx) +
+
+
+
+
+ ); +} + +export function FeaturesSection() { + const features = [ + { + icon: Globe2, + title: "100+ Languages", + description: "Translate between any language pair with high accuracy using AI models", + color: "text-blue-400", + }, + { + icon: FileText, + title: "Preserve Formatting", + description: "All styles, fonts, colors, tables, and charts remain intact", + color: "text-green-400", + }, + { + icon: Zap, + title: "Lightning Fast", + description: "Batch processing translates entire documents in seconds", + color: "text-amber-400", + }, + { + icon: Shield, + title: "Secure & Private", + description: "Your documents are encrypted and never stored permanently", + color: "text-purple-400", + }, + { + icon: Server, + title: "Self-Host Option", + description: "Use your own Ollama server for complete privacy and unlimited usage", + color: "text-orange-400", + }, + { + icon: Sparkles, + title: "Multiple AI Providers", + description: "Choose from Google, DeepL, OpenAI, or local Ollama models", + color: "text-teal-400", + }, + ]; + + return ( +
+
+
+

+ Everything You Need for Document Translation +

+

+ Professional-grade translation with enterprise features, available to everyone. +

+
+ +
+ {features.map((feature) => { + const Icon = feature.icon; + return ( +
+ +

{feature.title}

+

{feature.description}

+
+ ); + })} +
+
+
+ ); +} + +export function PricingPreview() { + const plans = [ + { + name: "Free", + price: "$0", + description: "Perfect for trying out", + features: ["3 documents/day", "10 pages/doc", "Ollama only"], + cta: "Get Started", + href: "/auth/register", + }, + { + name: "Pro", + price: "$29", + period: "/month", + description: "For professionals", + features: ["200 documents/month", "All providers", "API access", "Priority support"], + cta: "Start Free Trial", + href: "/pricing", + popular: true, + }, + { + name: "Business", + price: "$79", + period: "/month", + description: "For teams", + features: ["1000 documents/month", "Team management", "Dedicated support", "SLA"], + cta: "Contact Sales", + href: "/pricing", + }, + ]; + + return ( +
+
+
+

+ Simple, Transparent Pricing +

+

+ Start free, upgrade when you need more. +

+
+ +
+ {plans.map((plan) => ( +
+ {plan.popular && ( + + Most Popular + + )} + +

{plan.name}

+

{plan.description}

+ +
+ {plan.price} + {plan.period && {plan.period}} +
+ +
    + {plan.features.map((feature) => ( +
  • + + {feature} +
  • + ))} +
+ + + + +
+ ))} +
+ +
+ + View all plans and features → + +
+
+
+ ); +} + +export function SelfHostCTA() { + return ( +
+
+
+ +

+ Prefer Self-Hosting? +

+

+ Run your own Ollama server for complete privacy and unlimited translations. + No API costs, no quotas, your data stays on your machine. +

+ + + +
+
+
+ ); +} diff --git a/frontend/src/components/sidebar.tsx b/frontend/src/components/sidebar.tsx index 4ce010c..f7c59fd 100644 --- a/frontend/src/components/sidebar.tsx +++ b/frontend/src/components/sidebar.tsx @@ -2,6 +2,7 @@ import Link from "next/link"; import { usePathname } from "next/navigation"; +import { useState, useEffect } from "react"; import { cn } from "@/lib/utils"; import { Settings, @@ -9,6 +10,11 @@ import { BookText, Upload, Shield, + CreditCard, + LayoutDashboard, + LogIn, + Crown, + LogOut, } from "lucide-react"; import { Tooltip, @@ -16,6 +22,14 @@ import { TooltipProvider, TooltipTrigger, } from "@/components/ui/tooltip"; +import { Badge } from "@/components/ui/badge"; + +interface User { + id: string; + name: string; + email: string; + plan: string; +} const navigation = [ { @@ -55,6 +69,35 @@ const adminNavigation = [ export function Sidebar() { const pathname = usePathname(); + const [user, setUser] = useState(null); + + useEffect(() => { + // Check for user in localStorage + const storedUser = localStorage.getItem("user"); + if (storedUser) { + try { + setUser(JSON.parse(storedUser)); + } catch { + setUser(null); + } + } + }, []); + + const handleLogout = () => { + localStorage.removeItem("token"); + localStorage.removeItem("refresh_token"); + localStorage.removeItem("user"); + setUser(null); + window.location.href = "/"; + }; + + const planColors: Record = { + free: "bg-zinc-600", + starter: "bg-blue-500", + pro: "bg-teal-500", + business: "bg-purple-500", + enterprise: "bg-amber-500", + }; return ( @@ -95,6 +138,53 @@ export function Sidebar() { ); })} + + {/* User Section */} + {user && ( +
+

Account

+ + + + + + Dashboard + + + +

View your usage and settings

+
+
+ + + + + + Upgrade Plan + + + +

View plans and pricing

+
+
+
+ )} {/* Admin Section */}
@@ -130,15 +220,41 @@ export function Sidebar() { {/* User section at bottom */}
-
-
- U + {user ? ( +
+
+
+ {user.name.charAt(0).toUpperCase()} +
+
+ {user.name} + + {user.plan.charAt(0).toUpperCase() + user.plan.slice(1)} + +
+
+
-
- User - Translator + ) : ( +
+ + + + + +
-
+ )}
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