feat: production deployment - full update with providers, admin, glossaries, pricing, tests
Major changes across backend, frontend, infrastructure: - Provider system with model selection (Google, DeepL, OpenAI, Ollama, Google Cloud) - Admin panel: user management, pricing, settings - Glossary system with CSV import/export - Subscription and tier quota management - Security hardening (rate limiting, API key auth, path traversal fixes) - Docker compose for dev, prod, and IONOS deployment - Alembic migrations for new tables - Frontend: dashboard, pricing page, landing page, i18n (en/fr) - Test suite and verification scripts Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -15,20 +15,36 @@ except ImportError:
|
||||
|
||||
from models.subscription import PlanType, PLANS, CREDIT_PACKAGES, SubscriptionStatus
|
||||
from services.auth_service import get_user_by_id, update_user, add_credits
|
||||
from services.pricing_config import (
|
||||
get_subscription_line_amount_eur,
|
||||
stripe_price_ids_for_plan,
|
||||
)
|
||||
|
||||
|
||||
# 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 _get_stripe_secret_key() -> str:
|
||||
"""Read Stripe secret key at runtime to support hot-reload/admin updates."""
|
||||
return os.getenv("STRIPE_SECRET_KEY", "").strip()
|
||||
|
||||
|
||||
def _ensure_stripe_client_configured() -> bool:
|
||||
"""Configure Stripe SDK lazily with the latest key from environment."""
|
||||
if not STRIPE_AVAILABLE:
|
||||
return False
|
||||
secret = _get_stripe_secret_key()
|
||||
if not secret:
|
||||
return False
|
||||
stripe.api_key = secret
|
||||
return True
|
||||
|
||||
|
||||
def is_stripe_configured() -> bool:
|
||||
"""Check if Stripe is properly configured"""
|
||||
return STRIPE_AVAILABLE and bool(STRIPE_SECRET_KEY)
|
||||
return _ensure_stripe_client_configured()
|
||||
|
||||
|
||||
async def create_checkout_session(
|
||||
@@ -47,10 +63,37 @@ async def create_checkout_session(
|
||||
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"}
|
||||
plan_id = plan.value
|
||||
|
||||
mid, yid = stripe_price_ids_for_plan(plan_id)
|
||||
price_id = mid if billing_period == "monthly" else yid
|
||||
|
||||
# If no Stripe price id is configured, fall back to inline price_data.
|
||||
# This makes test-mode checkout work with only STRIPE_SECRET_KEY configured.
|
||||
line_item: Dict[str, Any]
|
||||
if price_id and price_id not in ("price_xxx", ""):
|
||||
line_item = {"price": price_id, "quantity": 1}
|
||||
else:
|
||||
amount = get_subscription_line_amount_eur(plan, billing_period)
|
||||
if amount in (None, "", -1) or float(amount) <= 0:
|
||||
return {
|
||||
"error": "Impossible de créer le checkout: tarif invalide pour ce forfait."
|
||||
}
|
||||
|
||||
amount_cents = int(round(float(amount) * 100))
|
||||
interval = "year" if billing_period == "yearly" else "month"
|
||||
line_item = {
|
||||
"price_data": {
|
||||
"currency": "eur",
|
||||
"unit_amount": amount_cents,
|
||||
"recurring": {"interval": interval},
|
||||
"product_data": {
|
||||
"name": f"Office Translator - {plan_config.get('name', plan_id)}"
|
||||
},
|
||||
},
|
||||
"quantity": 1,
|
||||
}
|
||||
|
||||
|
||||
try:
|
||||
# Create or get Stripe customer
|
||||
@@ -70,7 +113,7 @@ async def create_checkout_session(
|
||||
customer=customer_id,
|
||||
mode="subscription",
|
||||
payment_method_types=["card"],
|
||||
line_items=[{"price": price_id, "quantity": 1}],
|
||||
line_items=[line_item],
|
||||
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},
|
||||
@@ -87,6 +130,50 @@ async def create_checkout_session(
|
||||
return {"error": str(e)}
|
||||
|
||||
|
||||
async def sync_checkout_session(
|
||||
user_id: str,
|
||||
session_id: str,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Sync a completed Stripe Checkout session to local user state.
|
||||
Useful in local/dev environments where webhooks may not reach the backend.
|
||||
"""
|
||||
if not is_stripe_configured():
|
||||
return {"error": "Stripe not configured"}
|
||||
|
||||
user = get_user_by_id(user_id)
|
||||
if not user:
|
||||
return {"error": "User not found"}
|
||||
|
||||
if not session_id or not session_id.startswith("cs_"):
|
||||
return {"error": "Invalid checkout session id"}
|
||||
|
||||
try:
|
||||
session = stripe.checkout.Session.retrieve(session_id, expand=["subscription"])
|
||||
except Exception as e:
|
||||
return {"error": f"Unable to retrieve checkout session: {str(e)}"}
|
||||
|
||||
metadata = session.get("metadata", {}) or {}
|
||||
session_user_id = metadata.get("user_id")
|
||||
session_customer_id = session.get("customer")
|
||||
|
||||
# Security check: session must belong to current authenticated user.
|
||||
if session_user_id and session_user_id != user_id:
|
||||
return {"error": "Checkout session does not belong to current user"}
|
||||
if user.stripe_customer_id and session_customer_id and session_customer_id != user.stripe_customer_id:
|
||||
return {"error": "Checkout customer mismatch"}
|
||||
|
||||
if session.get("status") != "complete":
|
||||
return {"error": "Checkout session is not completed yet"}
|
||||
|
||||
await handle_checkout_completed(session)
|
||||
return {
|
||||
"status": "synced",
|
||||
"plan": metadata.get("plan"),
|
||||
"session_id": session_id,
|
||||
}
|
||||
|
||||
|
||||
async def create_credits_checkout(
|
||||
user_id: str,
|
||||
package_index: int,
|
||||
@@ -193,12 +280,25 @@ async def handle_checkout_completed(session: Dict):
|
||||
# It's a subscription
|
||||
plan = metadata.get("plan")
|
||||
if plan:
|
||||
subscription_id = session.get("subscription")
|
||||
subscription_raw = session.get("subscription")
|
||||
subscription_id = None
|
||||
subscription_ends_at = None
|
||||
|
||||
if isinstance(subscription_raw, str):
|
||||
subscription_id = subscription_raw
|
||||
elif subscription_raw:
|
||||
# Expanded subscription object (from sync path with expand=["subscription"])
|
||||
subscription_id = subscription_raw.get("id")
|
||||
period_end = subscription_raw.get("current_period_end")
|
||||
if period_end:
|
||||
subscription_ends_at = datetime.fromtimestamp(period_end).isoformat()
|
||||
|
||||
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
|
||||
"subscription_ends_at": subscription_ends_at,
|
||||
"docs_translated_this_month": 0,
|
||||
"pages_translated_this_month": 0,
|
||||
})
|
||||
|
||||
@@ -224,6 +324,7 @@ async def handle_subscription_updated(subscription: Dict):
|
||||
|
||||
update_user(user_id, {
|
||||
"subscription_status": status.value,
|
||||
"cancel_at_period_end": subscription.get("cancel_at_period_end", False),
|
||||
"subscription_ends_at": datetime.fromtimestamp(
|
||||
subscription.get("current_period_end", 0)
|
||||
).isoformat() if subscription.get("current_period_end") else None
|
||||
@@ -256,24 +357,37 @@ async def handle_payment_failed(invoice: Dict):
|
||||
|
||||
|
||||
async def cancel_subscription(user_id: str) -> Dict[str, Any]:
|
||||
"""Cancel a user's subscription"""
|
||||
"""Cancel a user's subscription at period end."""
|
||||
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"}
|
||||
|
||||
return {"error": "No active subscription found"}
|
||||
|
||||
try:
|
||||
# Cancel at period end
|
||||
subscription = stripe.Subscription.modify(
|
||||
user.stripe_subscription_id,
|
||||
cancel_at_period_end=True
|
||||
cancel_at_period_end=True,
|
||||
)
|
||||
|
||||
|
||||
cancel_at = None
|
||||
if subscription.cancel_at:
|
||||
cancel_at = datetime.fromtimestamp(subscription.cancel_at).isoformat()
|
||||
|
||||
subscription_ends_at = None
|
||||
if subscription.current_period_end:
|
||||
subscription_ends_at = datetime.fromtimestamp(subscription.current_period_end).isoformat()
|
||||
|
||||
update_user(user_id, {
|
||||
"cancel_at_period_end": True,
|
||||
"subscription_ends_at": subscription_ends_at,
|
||||
})
|
||||
|
||||
return {
|
||||
"status": "canceling",
|
||||
"cancel_at": datetime.fromtimestamp(subscription.cancel_at).isoformat() if subscription.cancel_at else None
|
||||
"cancel_at": cancel_at,
|
||||
"subscription_ends_at": subscription_ends_at,
|
||||
}
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
|
||||
Reference in New Issue
Block a user