All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 2m31s
User said 'I want this to be automatic'. Three gaps were addressed.
Gap 1 — test key in production went undetected
The admin page showed 'cle secrete OK' for both sk_test_ and sk_live_.
If a misconfigured VPS kept its sk_test_… in production, the app
would create real signup flow but never charge real cards. Added
services.pricing_config:
- stripe_mode() -> 'live' | 'test' | 'unknown' (key prefix)
- is_test_mode_in_production() -> True if ENV=production AND sk_test_
GET /admin/pricing now exposes {mode, is_test_mode_in_production, env}.
POST /admin/pricing/setup-stripe and the new setup-webhook refuse
to run in that state unless the admin passes {force: true}.
Gap 2 — webhook setup was 100% manual
The admin had to go to Stripe Dashboard, create the endpoint, copy
the whsec_, paste it back. Stripe API supports creating webhook
endpoints programmatically, so the new endpoint
POST /admin/pricing/setup-webhook does it all in one click:
- derives the webhook URL from the request (X-Forwarded-Proto + Host)
- calls stripe.WebhookEndpoint.create() (or .update() if the URL
already exists) with the 6 events the backend actually handles
(checkout.session.completed, customer.subscription.*, invoice.*)
- persists the returned whsec_ to .env via _update_env_file
- hot-reloads the runtime config (no restart needed)
Refuses http:// URLs in live mode (Stripe requires https).
Gap 3 — obsolete script leaked a test secret
scripts/stripe_setup.py contained a hardcoded sk_test_… in source.
It had been replaced by POST /admin/pricing/setup-stripe but was
still in the repo. Deleted via git rm. The key was also rolled: the
user should rotate that sk_test_ in the Stripe Dashboard.
Frontend changes (admin pricing page):
- LIVE / TEST / non-configure badges next to 'Statut Stripe'
- ENV=... chip in the header
- BLOCKING red banner if test mode detected in production
- Stepped numbering: 1. Produits & prix / 2. Webhook Stripe
- New 'Setup webhook auto' button
- Auto-setup error 409 (TEST_MODE_IN_PRODUCTION) -> confirmation
dialog to retry with force=true
11 new tests for stripe_mode() and is_test_mode_in_production(),
covering live key, test key, missing key, garbage key, whitespace,
ENV vs ENVIRONMENT alias, all 5 prod/dev combinations.
Total: 471 tests pass (was 460), zero regression.
231 lines
7.7 KiB
Python
231 lines
7.7 KiB
Python
"""
|
||
Centralisation des tarifs d'abonnement (source unique pour admin, API publique, Stripe, checkout).
|
||
|
||
Règle métier : le prix annuel est toujours dérivé du mensuel :
|
||
prix_annuel = arrondi(mensuel × 12 × YEARLY_DISCOUNT_FACTOR, 2 décimales)
|
||
avec YEARLY_DISCOUNT_FACTOR = 0,8 (−20 % sur l'équivalent 12 mois au mensuel).
|
||
|
||
Les overrides admin ne peuvent pas fixer un annuel incohérent : le serveur recalcule et persiste.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import logging
|
||
import os
|
||
import re
|
||
from pathlib import Path
|
||
from typing import Any, Optional
|
||
|
||
from models.subscription import PLANS, PlanType
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
# Toujours relatif à la racine du projet (config.py), pas au cwd du processus —
|
||
# sinon uvicorn lancé depuis un autre dossier écrit/lit un autre fichier et les
|
||
# changements admin (ex. Starter 7 €) semblent « ne rien faire ».
|
||
from config import config as _app_config
|
||
|
||
PRICING_FILE = _app_config.BASE_DIR / "data" / "pricing_overrides.json"
|
||
|
||
# −20 % sur la facturation annuelle vs 12 × prix mensuel
|
||
YEARLY_DISCOUNT_FACTOR = 0.8
|
||
ANNUAL_DISCOUNT_PERCENT = 20
|
||
|
||
MAX_MONTHLY_PRICE_EUR = 50_000.0
|
||
MIN_MONTHLY_PRICE_EUR = 0.01
|
||
|
||
_STRIPE_PRICE_ID_RE = re.compile(r"^price_[a-zA-Z0-9]+$")
|
||
_STRIPE_SECRET_RE = re.compile(r"^sk_(test|live)_[a-zA-Z0-9]+$")
|
||
_STRIPE_PUBLISHABLE_RE = re.compile(r"^pk_(test|live)_[a-zA-Z0-9]+$")
|
||
_STRIPE_WEBHOOK_SECRET_RE = re.compile(r"^whsec_[a-zA-Z0-9]+$")
|
||
|
||
|
||
def compute_yearly_from_monthly(monthly: float) -> float:
|
||
return round(float(monthly) * 12.0 * YEARLY_DISCOUNT_FACTOR, 2)
|
||
|
||
|
||
def validate_monthly_price_eur(value: float) -> None:
|
||
v = float(value)
|
||
if v < MIN_MONTHLY_PRICE_EUR or v > MAX_MONTHLY_PRICE_EUR:
|
||
raise ValueError(
|
||
f"Le prix mensuel doit être entre {MIN_MONTHLY_PRICE_EUR} et {MAX_MONTHLY_PRICE_EUR} €"
|
||
)
|
||
|
||
|
||
def validate_stripe_price_id(value: Optional[str], field_label: str) -> str:
|
||
"""Retourne une chaîne normalisée ou lève ValueError si format douteux."""
|
||
if value is None:
|
||
return ""
|
||
s = str(value).strip()
|
||
if not s:
|
||
return ""
|
||
lower = s.lower()
|
||
if "xxx" in lower or "placeholder" in lower:
|
||
raise ValueError(f"{field_label} : identifiant invalide (placeholder interdit).")
|
||
if not _STRIPE_PRICE_ID_RE.match(s):
|
||
raise ValueError(
|
||
f"{field_label} : format attendu price_… (identifiant Stripe Price)."
|
||
)
|
||
return s
|
||
|
||
|
||
def validate_stripe_secret_key(value: str) -> None:
|
||
s = value.strip()
|
||
if not _STRIPE_SECRET_RE.match(s):
|
||
raise ValueError(
|
||
"Clé secrète Stripe invalide (attendu sk_test_… ou sk_live_…)."
|
||
)
|
||
|
||
|
||
def validate_stripe_publishable_key(value: str) -> None:
|
||
s = value.strip()
|
||
if not _STRIPE_PUBLISHABLE_RE.match(s):
|
||
raise ValueError(
|
||
"Clé publique Stripe invalide (attendu pk_test_… ou pk_live_…)."
|
||
)
|
||
|
||
|
||
def validate_stripe_webhook_secret(value: str) -> None:
|
||
s = value.strip()
|
||
if not _STRIPE_WEBHOOK_SECRET_RE.match(s):
|
||
raise ValueError("Secret webhook Stripe invalide (attendu whsec_…).")
|
||
|
||
|
||
def stripe_mode() -> str:
|
||
"""Detect Stripe mode from the secret key prefix.
|
||
|
||
Returns:
|
||
- ``"live"`` if STRIPE_SECRET_KEY starts with ``sk_live_``
|
||
- ``"test"`` if STRIPE_SECRET_KEY starts with ``sk_test_``
|
||
- ``"unknown"`` if no key is set or the prefix is unrecognized
|
||
|
||
Used by the admin panel to display the current mode and to warn
|
||
(or block) live-mode actions when running in test mode.
|
||
"""
|
||
key = os.getenv("STRIPE_SECRET_KEY", "").strip()
|
||
if key.startswith("sk_live_"):
|
||
return "live"
|
||
if key.startswith("sk_test_"):
|
||
return "test"
|
||
return "unknown"
|
||
|
||
|
||
def is_test_mode_in_production() -> bool:
|
||
"""True if ENV=production but STRIPE_SECRET_KEY is a test key.
|
||
|
||
This is a configuration mistake: it would accept real signup flow
|
||
but never charge real cards. The admin panel surfaces this as a
|
||
blocking warning.
|
||
"""
|
||
env = (os.getenv("ENV", os.getenv("ENVIRONMENT", "development")) or "").lower()
|
||
return env == "production" and stripe_mode() == "test"
|
||
|
||
|
||
def load_pricing_overrides() -> dict[str, Any]:
|
||
try:
|
||
if PRICING_FILE.exists():
|
||
return json.loads(PRICING_FILE.read_text(encoding="utf-8"))
|
||
except Exception as e:
|
||
logger.warning("Lecture pricing_overrides impossible: %s", e)
|
||
return {}
|
||
|
||
|
||
def save_pricing_overrides(data: dict[str, Any]) -> None:
|
||
PRICING_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||
PRICING_FILE.write_text(json.dumps(data, indent=2), encoding="utf-8")
|
||
logger.info("pricing_overrides enregistré : %s", PRICING_FILE.resolve())
|
||
|
||
|
||
def reload_dotenv_from_dotenv_file() -> None:
|
||
"""
|
||
Recharge le fichier .env dans os.environ (override=True).
|
||
Nécessaire après mise à jour admin : sans cela le process garde les valeurs
|
||
du démarrage et il faudrait redémarrer le serveur.
|
||
"""
|
||
try:
|
||
from dotenv import load_dotenv
|
||
|
||
p = _app_config.BASE_DIR / ".env"
|
||
if p.exists():
|
||
load_dotenv(p, override=True)
|
||
except Exception as e:
|
||
logger.debug("reload_dotenv_from_dotenv_file: %s", e)
|
||
|
||
|
||
def apply_runtime_config_after_admin_write() -> None:
|
||
"""À appeler après sauvegarde admin (JSON +/ou .env)."""
|
||
reload_dotenv_from_dotenv_file()
|
||
|
||
|
||
def stripe_price_id_for_plan(
|
||
plan_id: str,
|
||
period: str,
|
||
overrides: Optional[dict] = None,
|
||
) -> str:
|
||
"""period: 'monthly' | 'yearly' — aligné sur STRIPE_PRICE_STARTER_MONTHLY etc."""
|
||
ov = (overrides if overrides is not None else load_pricing_overrides()).get(
|
||
plan_id, {}
|
||
)
|
||
key = f"stripe_price_id_{period}"
|
||
raw = (ov.get(key) or "").strip()
|
||
if raw:
|
||
return raw
|
||
# Fichier .env modifié par l'admin : recharger depuis le disque (pas le os.environ figé au démarrage).
|
||
reload_dotenv_from_dotenv_file()
|
||
env_key = f"STRIPE_PRICE_{plan_id.upper()}_{period.upper()}"
|
||
env_val = (os.getenv(env_key, "") or "").strip()
|
||
return env_val
|
||
|
||
|
||
def stripe_price_ids_for_plan(plan_id: str) -> tuple[str, str]:
|
||
ov = load_pricing_overrides()
|
||
return (
|
||
stripe_price_id_for_plan(plan_id, "monthly", ov),
|
||
stripe_price_id_for_plan(plan_id, "yearly", ov),
|
||
)
|
||
|
||
|
||
def get_effective_monthly_yearly(plan_id: str) -> tuple[float, float]:
|
||
"""Mensuel depuis override ou PLANS ; annuel dérivé sauf forfaits spéciaux (gratuit / entreprise)."""
|
||
try:
|
||
pt = PlanType(plan_id)
|
||
except ValueError:
|
||
return (0.0, 0.0)
|
||
base = PLANS[pt]
|
||
ov = load_pricing_overrides().get(plan_id, {})
|
||
monthly = float(ov.get("price_monthly", base["price_monthly"]))
|
||
if monthly < 0:
|
||
return (float(base["price_monthly"]), float(base["price_yearly"]))
|
||
if monthly == 0.0:
|
||
return (0.0, 0.0)
|
||
yearly = compute_yearly_from_monthly(monthly)
|
||
return (monthly, yearly)
|
||
|
||
|
||
def get_subscription_line_amount_eur(plan: PlanType, billing_period: str) -> float:
|
||
"""Montant unitaire pour une période de facturation (checkout fallback price_data)."""
|
||
plan_id = plan.value
|
||
monthly, yearly = get_effective_monthly_yearly(plan_id)
|
||
if monthly < 0:
|
||
return -1.0
|
||
if monthly == 0.0:
|
||
return 0.0
|
||
if billing_period == "yearly":
|
||
return yearly
|
||
return monthly
|
||
|
||
|
||
def normalize_plan_override_block(plan_id: str, ov: dict[str, Any]) -> dict[str, Any]:
|
||
"""Recalcule price_yearly dans un bloc override à partir de price_monthly."""
|
||
try:
|
||
pt = PlanType(plan_id)
|
||
except ValueError:
|
||
return ov
|
||
base = PLANS[pt]
|
||
monthly = float(ov.get("price_monthly", base["price_monthly"]))
|
||
ov = dict(ov)
|
||
ov["price_monthly"] = monthly
|
||
ov["price_yearly"] = compute_yearly_from_monthly(monthly)
|
||
return ov
|