feat(admin): Stripe live-mode safety + one-click webhook setup
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 2m31s
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.
This commit is contained in:
@@ -35,6 +35,8 @@ interface PricingData {
|
|||||||
}
|
}
|
||||||
interface StripeStatus {
|
interface StripeStatus {
|
||||||
configured: boolean;
|
configured: boolean;
|
||||||
|
mode: "live" | "test" | "unknown";
|
||||||
|
is_test_mode_in_production: boolean;
|
||||||
has_secret_key: boolean;
|
has_secret_key: boolean;
|
||||||
has_publishable_key: boolean;
|
has_publishable_key: boolean;
|
||||||
has_webhook_secret: boolean;
|
has_webhook_secret: boolean;
|
||||||
@@ -103,6 +105,9 @@ export default function AdminPricingPage() {
|
|||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [setupLoading, setSetupLoading] = useState(false);
|
const [setupLoading, setSetupLoading] = useState(false);
|
||||||
const [setupResult, setSetupResult] = useState<any>(null);
|
const [setupResult, setSetupResult] = useState<any>(null);
|
||||||
|
const [webhookLoading, setWebhookLoading] = useState(false);
|
||||||
|
const [webhookResult, setWebhookResult] = useState<any>(null);
|
||||||
|
const [envMode, setEnvMode] = useState<string>("development");
|
||||||
const [toast, setToast] = useState<{ type: "ok" | "err"; text: string } | null>(null);
|
const [toast, setToast] = useState<{ type: "ok" | "err"; text: string } | null>(null);
|
||||||
|
|
||||||
const getToken = () => useTranslationStore.getState().settings.adminToken ?? "";
|
const getToken = () => useTranslationStore.getState().settings.adminToken ?? "";
|
||||||
@@ -130,6 +135,7 @@ export default function AdminPricingPage() {
|
|||||||
setPricing(d);
|
setPricing(d);
|
||||||
}
|
}
|
||||||
if (j.stripe) setStripeStatus(j.stripe);
|
if (j.stripe) setStripeStatus(j.stripe);
|
||||||
|
if (j.meta?.env) setEnvMode(j.meta.env);
|
||||||
} else if (res.status === 401) {
|
} else if (res.status === 401) {
|
||||||
showToast("err", "Session admin expirée. Reconnectez-vous.");
|
showToast("err", "Session admin expirée. Reconnectez-vous.");
|
||||||
} else {
|
} else {
|
||||||
@@ -263,6 +269,33 @@ export default function AdminPricingPage() {
|
|||||||
showToast("err", j.errors?.[0]?.error || j.message || "Échec de la configuration Stripe.");
|
showToast("err", j.errors?.[0]?.error || j.message || "Échec de la configuration Stripe.");
|
||||||
}
|
}
|
||||||
loadPricing();
|
loadPricing();
|
||||||
|
} else if (res.status === 409 && j.error === "TEST_MODE_IN_PRODUCTION") {
|
||||||
|
// Give the user a way to override
|
||||||
|
const ok = window.confirm(
|
||||||
|
`${j.message}\n\nForcer la création en mode test ? (utile pour staging, à éviter en prod)`,
|
||||||
|
);
|
||||||
|
if (!ok) {
|
||||||
|
setSetupResult(j);
|
||||||
|
setSetupLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const retry = await fetch(`${API_BASE}/api/v1/admin/pricing/setup-stripe`, {
|
||||||
|
method: "POST",
|
||||||
|
cache: "no-store",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ force: true }),
|
||||||
|
});
|
||||||
|
const rj = await retry.json();
|
||||||
|
setSetupResult(rj);
|
||||||
|
if (retry.ok) {
|
||||||
|
showToast("ok", "✅ Produits créés (forcé).");
|
||||||
|
loadPricing();
|
||||||
|
} else {
|
||||||
|
showToast("err", rj.message || "Échec forcé.");
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
showToast("err", j.message || "Erreur lors de la configuration Stripe.");
|
showToast("err", j.message || "Erreur lors de la configuration Stripe.");
|
||||||
setSetupResult(j);
|
setSetupResult(j);
|
||||||
@@ -274,6 +307,69 @@ export default function AdminPricingPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const autoSetupWebhook = async () => {
|
||||||
|
const token = useTranslationStore.getState().settings.adminToken;
|
||||||
|
if (!token) {
|
||||||
|
showToast("err", "Session admin absente. Reconnectez-vous.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setWebhookLoading(true);
|
||||||
|
setWebhookResult(null);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE}/api/v1/admin/pricing/setup-webhook`, {
|
||||||
|
method: "POST",
|
||||||
|
cache: "no-store",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const j = await res.json();
|
||||||
|
if (res.ok) {
|
||||||
|
setWebhookResult(j);
|
||||||
|
if (j.data?.secret_saved) {
|
||||||
|
showToast("ok", "✅ Webhook Stripe créé/mis à jour et signing secret enregistré.");
|
||||||
|
} else {
|
||||||
|
showToast("ok", "✅ Webhook Stripe mis à jour. (Secret inchangé — il existait déjà.)");
|
||||||
|
}
|
||||||
|
loadPricing();
|
||||||
|
} else if (res.status === 409 && j.error === "TEST_MODE_IN_PRODUCTION") {
|
||||||
|
const ok = window.confirm(
|
||||||
|
`${j.message}\n\nForcer quand même ?`,
|
||||||
|
);
|
||||||
|
if (!ok) {
|
||||||
|
setWebhookResult(j);
|
||||||
|
setWebhookLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const retry = await fetch(`${API_BASE}/api/v1/admin/pricing/setup-webhook`, {
|
||||||
|
method: "POST",
|
||||||
|
cache: "no-store",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ force: true }),
|
||||||
|
});
|
||||||
|
const rj = await retry.json();
|
||||||
|
setWebhookResult(rj);
|
||||||
|
if (retry.ok) {
|
||||||
|
showToast("ok", "✅ Webhook créé (forcé).");
|
||||||
|
loadPricing();
|
||||||
|
} else {
|
||||||
|
showToast("err", rj.message || "Échec forcé.");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
showToast("err", j.message || "Erreur lors de la création du webhook.");
|
||||||
|
setWebhookResult(j);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
showToast("err", "Erreur réseau.");
|
||||||
|
} finally {
|
||||||
|
setWebhookLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const updatePlan = (planId: keyof PricingData, field: keyof PlanConfig, value: string | number) => {
|
const updatePlan = (planId: keyof PricingData, field: keyof PlanConfig, value: string | number) => {
|
||||||
setPricing((prev) => {
|
setPricing((prev) => {
|
||||||
const next = { ...prev[planId], [field]: value } as PlanConfig;
|
const next = { ...prev[planId], [field]: value } as PlanConfig;
|
||||||
@@ -328,9 +424,48 @@ export default function AdminPricingPage() {
|
|||||||
<CardTitle className="text-base flex items-center gap-2">
|
<CardTitle className="text-base flex items-center gap-2">
|
||||||
<Sparkles className="w-4 h-4 text-violet-400" />
|
<Sparkles className="w-4 h-4 text-violet-400" />
|
||||||
Statut Stripe
|
Statut Stripe
|
||||||
|
{stripeStatus?.mode === "live" && (
|
||||||
|
<Badge className="ml-2 bg-emerald-600/20 text-emerald-300 border-emerald-500/40 text-[10px]">
|
||||||
|
● LIVE
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{stripeStatus?.mode === "test" && (
|
||||||
|
<Badge className="ml-2 bg-amber-600/20 text-amber-300 border-amber-500/40 text-[10px]">
|
||||||
|
● TEST
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{stripeStatus?.mode === "unknown" && (
|
||||||
|
<Badge variant="outline" className="ml-2 text-muted-foreground text-[10px]">
|
||||||
|
non configuré
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
<span className="ml-auto text-xs text-muted-foreground font-mono">
|
||||||
|
ENV={envMode}
|
||||||
|
</span>
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
|
{/* BLOCKING warning if test mode in production */}
|
||||||
|
{stripeStatus?.is_test_mode_in_production && (
|
||||||
|
<div className="flex items-start gap-3 p-4 rounded-xl border-2 border-red-500/50 bg-red-900/20 text-sm">
|
||||||
|
<AlertTriangle className="w-5 h-5 text-red-400 flex-shrink-0 mt-0.5" />
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="text-red-300 font-bold">
|
||||||
|
⚠️ CLÉ STRIPE TEST EN PRODUCTION
|
||||||
|
</p>
|
||||||
|
<p className="text-red-400/90 text-xs mt-1 leading-relaxed">
|
||||||
|
<code className="bg-red-950/50 px-1.5 py-0.5 rounded text-red-200">ENV=production</code>{" "}
|
||||||
|
mais{" "}
|
||||||
|
<code className="bg-red-950/50 px-1.5 py-0.5 rounded text-red-200">STRIPE_SECRET_KEY</code>{" "}
|
||||||
|
commence par <code className="bg-red-950/50 px-1.5 py-0.5 rounded text-red-200">sk_test_…</code>.
|
||||||
|
Les paiements en prod ne fonctionneront PAS — soit tu remplaces par{" "}
|
||||||
|
<code className="bg-red-950/50 px-1.5 py-0.5 rounded text-red-200">sk_live_…</code> dans
|
||||||
|
.env.ionos, soit tu remets <code className="bg-red-950/50 px-1.5 py-0.5 rounded text-red-200">ENV=staging</code>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="grid grid-cols-3 gap-3">
|
<div className="grid grid-cols-3 gap-3">
|
||||||
{[
|
{[
|
||||||
{ label: "Clé secrète", ok: stripeStatus?.has_secret_key },
|
{ label: "Clé secrète", ok: stripeStatus?.has_secret_key },
|
||||||
@@ -350,10 +485,10 @@ export default function AdminPricingPage() {
|
|||||||
|
|
||||||
<Separator />
|
<Separator />
|
||||||
|
|
||||||
{/* Auto-setup button */}
|
{/* Auto-setup button (products + prices) */}
|
||||||
<div className="flex items-start gap-4">
|
<div className="flex items-start gap-4">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<p className="text-sm font-medium text-foreground">Configuration automatique Stripe</p>
|
<p className="text-sm font-medium text-foreground">1. Produits & prix</p>
|
||||||
<p className="text-xs text-muted-foreground mt-1">
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
Crée automatiquement les 3 produits (Starter, Pro, Business) et les 6 prix (mensuel + annuel) dans votre compte Stripe, puis sauvegarde les Price IDs dans le .env.
|
Crée automatiquement les 3 produits (Starter, Pro, Business) et les 6 prix (mensuel + annuel) dans votre compte Stripe, puis sauvegarde les Price IDs dans le .env.
|
||||||
</p>
|
</p>
|
||||||
@@ -387,6 +522,45 @@ export default function AdminPricingPage() {
|
|||||||
|
|
||||||
<Separator />
|
<Separator />
|
||||||
|
|
||||||
|
{/* Auto-setup webhook */}
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="text-sm font-medium text-foreground">2. Webhook Stripe</p>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
Crée ou met à jour l'endpoint webhook dans Stripe pointant sur ce backend, et enregistre automatiquement le <code className="text-foreground/80">whsec_…</code> dans le .env.
|
||||||
|
L'URL est déduite de la requête courante (header Host + X-Forwarded-Proto).
|
||||||
|
</p>
|
||||||
|
{webhookResult?.data && (
|
||||||
|
<div className="mt-2 p-3 rounded-lg bg-secondary/40 text-xs space-y-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<CheckCircle className="w-3 h-3 text-emerald-400" />
|
||||||
|
<span>ID: <code>{webhookResult.data.id}</code></span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-muted-foreground">
|
||||||
|
<ExternalLink className="w-3 h-3" />
|
||||||
|
<span className="break-all">{webhookResult.data.url}</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-muted-foreground">
|
||||||
|
Events: <code>{(webhookResult.data.enabled_events || []).length}</code> abonné(s)
|
||||||
|
{webhookResult.data.created ? " — endpoint créé" : " — endpoint mis à jour"}
|
||||||
|
{webhookResult.data.secret_saved ? " — secret enregistré" : " — secret déjà connu"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
onClick={autoSetupWebhook}
|
||||||
|
disabled={webhookLoading || !stripeStatus?.has_secret_key}
|
||||||
|
variant="outline"
|
||||||
|
className="gap-2 flex-shrink-0 border-violet-500/40 text-violet-300 hover:bg-violet-500/10"
|
||||||
|
>
|
||||||
|
{webhookLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : <RefreshCw className="w-4 h-4" />}
|
||||||
|
{webhookLoading ? "Configuration…" : "Setup webhook auto"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
{/* Manual Stripe keys */}
|
{/* Manual Stripe keys */}
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium text-foreground mb-3">Clés Stripe (laisser vide pour garder les valeurs actuelles)</p>
|
<p className="text-sm font-medium text-foreground mb-3">Clés Stripe (laisser vide pour garder les valeurs actuelles)</p>
|
||||||
|
|||||||
@@ -1477,10 +1477,14 @@ async def get_pricing_config(admin_id: str = Depends(require_admin)):
|
|||||||
}
|
}
|
||||||
|
|
||||||
stripe_configured = bool(os.getenv("STRIPE_SECRET_KEY", "").strip().startswith("sk_"))
|
stripe_configured = bool(os.getenv("STRIPE_SECRET_KEY", "").strip().startswith("sk_"))
|
||||||
|
mode = pricing_cfg.stripe_mode()
|
||||||
|
is_test_in_prod = pricing_cfg.is_test_mode_in_production()
|
||||||
return JSONResponse(status_code=200, content={
|
return JSONResponse(status_code=200, content={
|
||||||
"data": result,
|
"data": result,
|
||||||
"stripe": {
|
"stripe": {
|
||||||
"configured": stripe_configured,
|
"configured": stripe_configured,
|
||||||
|
"mode": mode, # "live" | "test" | "unknown"
|
||||||
|
"is_test_mode_in_production": is_test_in_prod,
|
||||||
"has_secret_key": bool(os.getenv("STRIPE_SECRET_KEY", "").strip()),
|
"has_secret_key": bool(os.getenv("STRIPE_SECRET_KEY", "").strip()),
|
||||||
"has_publishable_key": bool(os.getenv("STRIPE_PUBLISHABLE_KEY", "").strip()),
|
"has_publishable_key": bool(os.getenv("STRIPE_PUBLISHABLE_KEY", "").strip()),
|
||||||
"has_webhook_secret": bool(os.getenv("STRIPE_WEBHOOK_SECRET", "").strip()),
|
"has_webhook_secret": bool(os.getenv("STRIPE_WEBHOOK_SECRET", "").strip()),
|
||||||
@@ -1489,6 +1493,7 @@ async def get_pricing_config(admin_id: str = Depends(require_admin)):
|
|||||||
"meta": {
|
"meta": {
|
||||||
"annual_discount_percent": pricing_cfg.ANNUAL_DISCOUNT_PERCENT,
|
"annual_discount_percent": pricing_cfg.ANNUAL_DISCOUNT_PERCENT,
|
||||||
"yearly_discount_factor": pricing_cfg.YEARLY_DISCOUNT_FACTOR,
|
"yearly_discount_factor": pricing_cfg.YEARLY_DISCOUNT_FACTOR,
|
||||||
|
"env": (os.getenv("ENV", os.getenv("ENVIRONMENT", "development")) or "development").lower(),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -1591,7 +1596,7 @@ async def update_pricing_config(
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/pricing/setup-stripe")
|
@router.post("/pricing/setup-stripe")
|
||||||
async def setup_stripe_products(admin_id: str = Depends(require_admin)):
|
async def setup_stripe_products(request: Request, admin_id: str = Depends(require_admin)):
|
||||||
"""Auto-create Stripe products & prices from backend, then save IDs to .env."""
|
"""Auto-create Stripe products & prices from backend, then save IDs to .env."""
|
||||||
secret_key = os.getenv("STRIPE_SECRET_KEY", "").strip()
|
secret_key = os.getenv("STRIPE_SECRET_KEY", "").strip()
|
||||||
if not secret_key or not secret_key.startswith("sk_"):
|
if not secret_key or not secret_key.startswith("sk_"):
|
||||||
@@ -1600,6 +1605,27 @@ async def setup_stripe_products(admin_id: str = Depends(require_admin)):
|
|||||||
"message": "STRIPE_SECRET_KEY manquante ou invalide. Configurez-la d'abord.",
|
"message": "STRIPE_SECRET_KEY manquante ou invalide. Configurez-la d'abord.",
|
||||||
})
|
})
|
||||||
|
|
||||||
|
# Block test-mode auto-setup in production unless the admin forces it.
|
||||||
|
# Otherwise we'd create TEST products in the LIVE account context,
|
||||||
|
# which silently fails for real users and corrupts the prod pricing.
|
||||||
|
body: dict = {}
|
||||||
|
try:
|
||||||
|
body = await request.json()
|
||||||
|
except Exception:
|
||||||
|
body = {}
|
||||||
|
force = bool(body.get("force", False))
|
||||||
|
if pricing_cfg.is_test_mode_in_production() and not force:
|
||||||
|
return JSONResponse(status_code=409, content={
|
||||||
|
"error": "TEST_MODE_IN_PRODUCTION",
|
||||||
|
"message": (
|
||||||
|
"Vous êtes en production (ENV=production) mais STRIPE_SECRET_KEY "
|
||||||
|
"est une clé de test (sk_test_…). Créez des produits TEST dans un "
|
||||||
|
"compte TEST ou basculez sur une clé sk_live_… . Pour forcer, "
|
||||||
|
"relancez avec {\"force\": true} (à vos risques)."
|
||||||
|
),
|
||||||
|
"mode": pricing_cfg.stripe_mode(),
|
||||||
|
})
|
||||||
|
|
||||||
try:
|
try:
|
||||||
import stripe as _stripe
|
import stripe as _stripe
|
||||||
_stripe.api_key = secret_key
|
_stripe.api_key = secret_key
|
||||||
@@ -1689,3 +1715,160 @@ async def setup_stripe_products(admin_id: str = Depends(require_admin)):
|
|||||||
"env_updated": list(env_updates.keys()),
|
"env_updated": list(env_updates.keys()),
|
||||||
"meta": {},
|
"meta": {},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/pricing/setup-webhook")
|
||||||
|
async def setup_stripe_webhook(request: Request, admin_id: str = Depends(require_admin)):
|
||||||
|
"""Auto-create the Stripe webhook endpoint and save its signing secret.
|
||||||
|
|
||||||
|
The webhook URL is derived from the request's host headers
|
||||||
|
(X-Forwarded-Proto + Host). In production, the URL must be HTTPS
|
||||||
|
and publicly reachable from Stripe.
|
||||||
|
|
||||||
|
Body (optional):
|
||||||
|
- ``url_override`` (str): explicit webhook URL if auto-detection
|
||||||
|
doesn't match your deployment (e.g. behind a custom reverse proxy)
|
||||||
|
- ``force`` (bool): if true, allow auto-setup in test mode even
|
||||||
|
when running in production (useful for staging)
|
||||||
|
"""
|
||||||
|
secret_key = os.getenv("STRIPE_SECRET_KEY", "").strip()
|
||||||
|
if not secret_key or not secret_key.startswith("sk_"):
|
||||||
|
return JSONResponse(status_code=400, content={
|
||||||
|
"error": "STRIPE_NOT_CONFIGURED",
|
||||||
|
"message": "STRIPE_SECRET_KEY manquante ou invalide. Configurez-la d'abord.",
|
||||||
|
})
|
||||||
|
|
||||||
|
mode = pricing_cfg.stripe_mode()
|
||||||
|
is_test_in_prod = pricing_cfg.is_test_mode_in_production()
|
||||||
|
|
||||||
|
# Read optional body
|
||||||
|
body: dict = {}
|
||||||
|
try:
|
||||||
|
body = await request.json()
|
||||||
|
except Exception:
|
||||||
|
body = {}
|
||||||
|
|
||||||
|
force = bool(body.get("force", False))
|
||||||
|
url_override = (body.get("url_override") or "").strip()
|
||||||
|
|
||||||
|
if is_test_in_prod and not force:
|
||||||
|
return JSONResponse(status_code=409, content={
|
||||||
|
"error": "TEST_MODE_IN_PRODUCTION",
|
||||||
|
"message": (
|
||||||
|
"Vous êtes en production (ENV=production) mais STRIPE_SECRET_KEY "
|
||||||
|
"est une clé de test (sk_test_…). Basculez sur sk_live_… ou "
|
||||||
|
"relancez avec {\"force\": true} pour créer un webhook test "
|
||||||
|
"explicitement (à des fins de staging)."
|
||||||
|
),
|
||||||
|
"mode": mode,
|
||||||
|
})
|
||||||
|
|
||||||
|
# Build the webhook URL from the request (host + forwarded proto)
|
||||||
|
if url_override:
|
||||||
|
webhook_url = url_override
|
||||||
|
else:
|
||||||
|
xfp = request.headers.get("x-forwarded-proto", "")
|
||||||
|
scheme = xfp.split(",")[0].strip() if xfp else (request.url.scheme or "https")
|
||||||
|
host = (
|
||||||
|
request.headers.get("x-forwarded-host")
|
||||||
|
or request.headers.get("host")
|
||||||
|
or request.url.netloc
|
||||||
|
)
|
||||||
|
webhook_url = f"{scheme}://{host}/api/v1/webhook/stripe"
|
||||||
|
|
||||||
|
# Basic sanity check
|
||||||
|
if not webhook_url.startswith(("http://", "https://")):
|
||||||
|
return JSONResponse(status_code=400, content={
|
||||||
|
"error": "INVALID_URL",
|
||||||
|
"message": f"URL webhook invalide : {webhook_url!r}",
|
||||||
|
})
|
||||||
|
if mode == "live" and webhook_url.startswith("http://"):
|
||||||
|
return JSONResponse(status_code=400, content={
|
||||||
|
"error": "INSECURE_URL_FOR_LIVE_MODE",
|
||||||
|
"message": (
|
||||||
|
"En mode Live, l'URL du webhook doit être HTTPS. "
|
||||||
|
f"URL actuelle : {webhook_url}. Utilisez url_override=https://…"
|
||||||
|
),
|
||||||
|
})
|
||||||
|
|
||||||
|
try:
|
||||||
|
import stripe as _stripe
|
||||||
|
_stripe.api_key = secret_key
|
||||||
|
|
||||||
|
# The events the backend actually handles (see payment_service.handle_webhook).
|
||||||
|
# If we send more events than the backend expects, Stripe will retry on
|
||||||
|
# signature mismatches and waste API quota. Keep this list aligned with
|
||||||
|
# the handler.
|
||||||
|
events = [
|
||||||
|
"checkout.session.completed",
|
||||||
|
"customer.subscription.created",
|
||||||
|
"customer.subscription.updated",
|
||||||
|
"customer.subscription.deleted",
|
||||||
|
"invoice.paid",
|
||||||
|
"invoice.payment_failed",
|
||||||
|
]
|
||||||
|
|
||||||
|
# Avoid duplicates: list existing endpoints and reuse one that already
|
||||||
|
# points to the same URL.
|
||||||
|
existing_list = _stripe.WebhookEndpoint.list(limit=20)
|
||||||
|
existing = next(
|
||||||
|
(e for e in existing_list.data if e.url == webhook_url),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if existing:
|
||||||
|
# Update the existing endpoint to make sure its events are in sync
|
||||||
|
webhook = _stripe.WebhookEndpoint.update(
|
||||||
|
existing.id,
|
||||||
|
enabled_events=events,
|
||||||
|
)
|
||||||
|
created = False
|
||||||
|
else:
|
||||||
|
webhook = _stripe.WebhookEndpoint.create(
|
||||||
|
url=webhook_url,
|
||||||
|
enabled_events=events,
|
||||||
|
description="Office Translator — auto-created by admin panel",
|
||||||
|
)
|
||||||
|
created = True
|
||||||
|
|
||||||
|
# ``webhook.secret`` is only returned at creation time by Stripe. On
|
||||||
|
# update it is None, so we re-use the existing STRIPE_WEBHOOK_SECRET
|
||||||
|
# if the endpoint already existed.
|
||||||
|
new_secret = webhook.get("secret") or os.getenv("STRIPE_WEBHOOK_SECRET", "").strip()
|
||||||
|
|
||||||
|
env_updates = {}
|
||||||
|
if new_secret:
|
||||||
|
pricing_cfg.validate_stripe_webhook_secret(new_secret)
|
||||||
|
env_updates["STRIPE_WEBHOOK_SECRET"] = new_secret
|
||||||
|
if env_updates:
|
||||||
|
_update_env_file(env_updates)
|
||||||
|
pricing_cfg.apply_runtime_config_after_admin_write()
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"admin_stripe_webhook_setup by {admin_id}: url={webhook_url} "
|
||||||
|
f"created={created} mode={mode}"
|
||||||
|
)
|
||||||
|
return JSONResponse(status_code=200, content={
|
||||||
|
"data": {
|
||||||
|
"id": webhook.id,
|
||||||
|
"url": webhook.url,
|
||||||
|
"enabled_events": webhook.enabled_events,
|
||||||
|
"status": webhook.status,
|
||||||
|
"created": created,
|
||||||
|
"secret_saved": bool(new_secret),
|
||||||
|
},
|
||||||
|
"meta": {
|
||||||
|
"mode": mode,
|
||||||
|
"env": (os.getenv("ENV", os.getenv("ENVIRONMENT", "development")) or "development").lower(),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
except ImportError:
|
||||||
|
return JSONResponse(status_code=500, content={
|
||||||
|
"error": "STRIPE_NOT_INSTALLED",
|
||||||
|
"message": "Le module stripe n'est pas installé sur le serveur.",
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("admin_stripe_webhook_setup_failed")
|
||||||
|
return JSONResponse(status_code=500, content={
|
||||||
|
"error": "STRIPE_API_ERROR",
|
||||||
|
"message": str(e),
|
||||||
|
})
|
||||||
|
|||||||
@@ -1,77 +0,0 @@
|
|||||||
"""
|
|
||||||
Script de setup Stripe : crée les produits et prix dans Stripe Dashboard.
|
|
||||||
Lance avec : python scripts/stripe_setup.py
|
|
||||||
"""
|
|
||||||
import stripe
|
|
||||||
import sys
|
|
||||||
|
|
||||||
SK = "sk_test_51SkSHkCKXUJE51jnbEtXZ0nKiTHTa8ohDwLH8fZiDVEx6Ze0g5dg4fGJJgX1VgNHvF93GE3HTramT3oQrCaqOxid00OXTcZlsW"
|
|
||||||
stripe.api_key = SK
|
|
||||||
|
|
||||||
PLANS = [
|
|
||||||
{"id": "starter", "name": "Wordly Starter", "monthly_eur": 900, "yearly_eur": 8640},
|
|
||||||
{"id": "pro", "name": "Wordly Pro", "monthly_eur": 1900, "yearly_eur": 18240},
|
|
||||||
{"id": "business","name": "Wordly Business","monthly_eur": 4900, "yearly_eur": 47040},
|
|
||||||
]
|
|
||||||
|
|
||||||
results = {}
|
|
||||||
for plan in PLANS:
|
|
||||||
print(f"\n>> Creation produit {plan['name']}...")
|
|
||||||
# Search existing product first
|
|
||||||
existing = stripe.Product.search(query=f"name:'{plan['name']}'", limit=1)
|
|
||||||
if existing.data:
|
|
||||||
product = existing.data[0]
|
|
||||||
print(f" Produit existant: {product.id}")
|
|
||||||
else:
|
|
||||||
product = stripe.Product.create(
|
|
||||||
name=plan["name"],
|
|
||||||
metadata={"plan_id": plan["id"]}
|
|
||||||
)
|
|
||||||
print(f" Produit créé: {product.id}")
|
|
||||||
|
|
||||||
# Monthly price
|
|
||||||
monthly_prices = stripe.Price.list(product=product.id, active=True, limit=10)
|
|
||||||
monthly_price = None
|
|
||||||
yearly_price = None
|
|
||||||
for p in monthly_prices.data:
|
|
||||||
if p.recurring and p.recurring.interval == "month" and p.unit_amount == plan["monthly_eur"]:
|
|
||||||
monthly_price = p
|
|
||||||
if p.recurring and p.recurring.interval == "year" and p.unit_amount == plan["yearly_eur"]:
|
|
||||||
yearly_price = p
|
|
||||||
|
|
||||||
if not monthly_price:
|
|
||||||
monthly_price = stripe.Price.create(
|
|
||||||
product=product.id,
|
|
||||||
unit_amount=plan["monthly_eur"],
|
|
||||||
currency="eur",
|
|
||||||
recurring={"interval": "month"},
|
|
||||||
metadata={"plan_id": plan["id"], "billing": "monthly"}
|
|
||||||
)
|
|
||||||
print(f" Prix mensuel créé: {monthly_price.id}")
|
|
||||||
else:
|
|
||||||
print(f" Prix mensuel existant: {monthly_price.id}")
|
|
||||||
|
|
||||||
if not yearly_price:
|
|
||||||
yearly_price = stripe.Price.create(
|
|
||||||
product=product.id,
|
|
||||||
unit_amount=plan["yearly_eur"],
|
|
||||||
currency="eur",
|
|
||||||
recurring={"interval": "year"},
|
|
||||||
metadata={"plan_id": plan["id"], "billing": "yearly"}
|
|
||||||
)
|
|
||||||
print(f" Prix annuel créé: {yearly_price.id}")
|
|
||||||
else:
|
|
||||||
print(f" Prix annuel existant: {yearly_price.id}")
|
|
||||||
|
|
||||||
results[plan["id"]] = {
|
|
||||||
"monthly": monthly_price.id,
|
|
||||||
"yearly": yearly_price.id,
|
|
||||||
}
|
|
||||||
|
|
||||||
print("\n" + "="*60)
|
|
||||||
print("Variables à ajouter dans .env.production et .env :")
|
|
||||||
print("="*60)
|
|
||||||
for plan_id, ids in results.items():
|
|
||||||
print(f"STRIPE_PRICE_{plan_id.upper()}_MONTHLY={ids['monthly']}")
|
|
||||||
print(f"STRIPE_PRICE_{plan_id.upper()}_YEARLY={ids['yearly']}")
|
|
||||||
print("="*60)
|
|
||||||
@@ -92,6 +92,36 @@ def validate_stripe_webhook_secret(value: str) -> None:
|
|||||||
raise ValueError("Secret webhook Stripe invalide (attendu whsec_…).")
|
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]:
|
def load_pricing_overrides() -> dict[str, Any]:
|
||||||
try:
|
try:
|
||||||
if PRICING_FILE.exists():
|
if PRICING_FILE.exists():
|
||||||
|
|||||||
103
tests/test_stripe_live_mode.py
Normal file
103
tests/test_stripe_live_mode.py
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
"""
|
||||||
|
Tests for Stripe live-mode safety checks.
|
||||||
|
|
||||||
|
Covers the helpers added in `services/pricing_config.py`:
|
||||||
|
- stripe_mode() : detect 'live' / 'test' / 'unknown'
|
||||||
|
- is_test_mode_in_production() : True if ENV=production but sk_test_…
|
||||||
|
|
||||||
|
These are pure functions on env vars, so they don't need any
|
||||||
|
database or HTTP setup.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import importlib
|
||||||
|
|
||||||
|
|
||||||
|
def _reload_pricing_config():
|
||||||
|
"""Reload pricing_config with the current os.environ (no cache)."""
|
||||||
|
import services.pricing_config as pc
|
||||||
|
importlib.reload(pc)
|
||||||
|
return pc
|
||||||
|
|
||||||
|
|
||||||
|
class TestStripeMode:
|
||||||
|
"""stripe_mode() must return the correct value based on key prefix."""
|
||||||
|
|
||||||
|
def setup_method(self):
|
||||||
|
self._saved_env = os.environ.copy()
|
||||||
|
|
||||||
|
def teardown_method(self):
|
||||||
|
os.environ.clear()
|
||||||
|
os.environ.update(self._saved_env)
|
||||||
|
|
||||||
|
def test_live_key_returns_live(self):
|
||||||
|
os.environ["STRIPE_SECRET_KEY"] = "sk_live_abc123XYZ"
|
||||||
|
pc = _reload_pricing_config()
|
||||||
|
assert pc.stripe_mode() == "live"
|
||||||
|
|
||||||
|
def test_test_key_returns_test(self):
|
||||||
|
os.environ["STRIPE_SECRET_KEY"] = "sk_test_abc123XYZ"
|
||||||
|
pc = _reload_pricing_config()
|
||||||
|
assert pc.stripe_mode() == "test"
|
||||||
|
|
||||||
|
def test_missing_key_returns_unknown(self):
|
||||||
|
os.environ.pop("STRIPE_SECRET_KEY", None)
|
||||||
|
pc = _reload_pricing_config()
|
||||||
|
assert pc.stripe_mode() == "unknown"
|
||||||
|
|
||||||
|
def test_empty_key_returns_unknown(self):
|
||||||
|
os.environ["STRIPE_SECRET_KEY"] = ""
|
||||||
|
pc = _reload_pricing_config()
|
||||||
|
assert pc.stripe_mode() == "unknown"
|
||||||
|
|
||||||
|
def test_garbage_key_returns_unknown(self):
|
||||||
|
os.environ["STRIPE_SECRET_KEY"] = "totally-not-a-stripe-key"
|
||||||
|
pc = _reload_pricing_config()
|
||||||
|
assert pc.stripe_mode() == "unknown"
|
||||||
|
|
||||||
|
def test_key_with_whitespace_is_trimmed(self):
|
||||||
|
os.environ["STRIPE_SECRET_KEY"] = " sk_live_abc123XYZ "
|
||||||
|
pc = _reload_pricing_config()
|
||||||
|
assert pc.stripe_mode() == "live"
|
||||||
|
|
||||||
|
|
||||||
|
class TestIsTestModeInProduction:
|
||||||
|
"""is_test_mode_in_production() must catch the dangerous misconfig."""
|
||||||
|
|
||||||
|
def setup_method(self):
|
||||||
|
self._saved_env = os.environ.copy()
|
||||||
|
|
||||||
|
def teardown_method(self):
|
||||||
|
os.environ.clear()
|
||||||
|
os.environ.update(self._saved_env)
|
||||||
|
|
||||||
|
def test_test_key_in_prod_returns_true(self):
|
||||||
|
os.environ["ENV"] = "production"
|
||||||
|
os.environ["STRIPE_SECRET_KEY"] = "sk_test_xxx"
|
||||||
|
pc = _reload_pricing_config()
|
||||||
|
assert pc.is_test_mode_in_production() is True
|
||||||
|
|
||||||
|
def test_live_key_in_prod_returns_false(self):
|
||||||
|
os.environ["ENV"] = "production"
|
||||||
|
os.environ["STRIPE_SECRET_KEY"] = "sk_live_xxx"
|
||||||
|
pc = _reload_pricing_config()
|
||||||
|
assert pc.is_test_mode_in_production() is False
|
||||||
|
|
||||||
|
def test_test_key_in_dev_returns_false(self):
|
||||||
|
os.environ["ENV"] = "development"
|
||||||
|
os.environ["STRIPE_SECRET_KEY"] = "sk_test_xxx"
|
||||||
|
pc = _reload_pricing_config()
|
||||||
|
assert pc.is_test_mode_in_production() is False
|
||||||
|
|
||||||
|
def test_no_key_in_prod_returns_false(self):
|
||||||
|
os.environ["ENV"] = "production"
|
||||||
|
os.environ.pop("STRIPE_SECRET_KEY", None)
|
||||||
|
pc = _reload_pricing_config()
|
||||||
|
assert pc.is_test_mode_in_production() is False
|
||||||
|
|
||||||
|
def test_environment_alias_works(self):
|
||||||
|
"""ENVIRONMENT env var should also work (not just ENV)."""
|
||||||
|
os.environ.pop("ENV", None)
|
||||||
|
os.environ["ENVIRONMENT"] = "production"
|
||||||
|
os.environ["STRIPE_SECRET_KEY"] = "sk_test_xxx"
|
||||||
|
pc = _reload_pricing_config()
|
||||||
|
assert pc.is_test_mode_in_production() is True
|
||||||
Reference in New Issue
Block a user