"use client"; import { useState, useEffect, useCallback } from "react"; import { CreditCard, Save, Loader2, CheckCircle, XCircle, Zap, RefreshCw, ExternalLink, AlertTriangle, KeyRound, Crown, Building2, Sparkles } from "lucide-react"; import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"; 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 { Separator } from "@/components/ui/separator"; import { useTranslationStore } from "@/lib/store"; import { API_BASE } from "@/lib/config"; import { ANNUAL_DISCOUNT_PERCENT, YEARLY_DISCOUNT_FACTOR, computeYearlyFromMonthly, } from "@/lib/pricing"; /* ─── Types ─── */ interface PlanConfig { name: string; price_monthly: number; price_yearly: number; stripe_price_id_monthly: string; stripe_price_id_yearly: string; } interface PricingData { starter: PlanConfig; pro: PlanConfig; business: PlanConfig; } interface StripeStatus { configured: boolean; mode: "live" | "test" | "unknown"; is_test_mode_in_production: boolean; has_secret_key: boolean; has_publishable_key: boolean; has_webhook_secret: boolean; publishable_key: string; } const PLAN_ICONS: Record = { starter: Zap, pro: Crown, business: Building2, }; /** Le backend refuse les placeholders (xxx) — envoyer une chaîne vide pour ne pas bloquer la sauvegarde des prix. */ function sanitizeStripePriceIdForSave(raw: string): string { const s = raw.trim(); if (!s) return ""; const lower = s.toLowerCase(); if (lower.includes("xxx") || lower.includes("placeholder")) return ""; return s; } const EMPTY_PLAN: PlanConfig = { name: "", price_monthly: 0, price_yearly: 0, stripe_price_id_monthly: "", stripe_price_id_yearly: "", }; /** Aligné sur models/subscription.py — éviter 0 € initial : le backend refuse < 0,01 € et la sauvegarde échouait pour Pro/Business. */ function defaultPricingData(): PricingData { const s = 9; const p = 19; const b = 49; return { starter: { ...EMPTY_PLAN, name: "Starter", price_monthly: s, price_yearly: computeYearlyFromMonthly(s), }, pro: { ...EMPTY_PLAN, name: "Pro", price_monthly: p, price_yearly: computeYearlyFromMonthly(p), }, business: { ...EMPTY_PLAN, name: "Business", price_monthly: b, price_yearly: computeYearlyFromMonthly(b), }, }; } export default function AdminPricingPage() { const [pricing, setPricing] = useState(defaultPricingData); const [stripeStatus, setStripeStatus] = useState(null); const [stripeKeys, setStripeKeys] = useState({ secret: "", publishable: "", webhook: "", }); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); const [setupLoading, setSetupLoading] = useState(false); const [setupResult, setSetupResult] = useState(null); const [webhookLoading, setWebhookLoading] = useState(false); const [webhookResult, setWebhookResult] = useState(null); const [envMode, setEnvMode] = useState("development"); const [toast, setToast] = useState<{ type: "ok" | "err"; text: string } | null>(null); const getToken = () => useTranslationStore.getState().settings.adminToken ?? ""; const showToast = (type: "ok" | "err", text: string) => { setToast({ type, text }); setTimeout(() => setToast(null), 5000); }; const loadPricing = useCallback(async () => { setLoading(true); try { const res = await fetch(`${API_BASE}/api/v1/admin/pricing`, { cache: "no-store", headers: { Authorization: `Bearer ${getToken()}` }, }); if (res.ok) { const j = await res.json(); if (j.data) { const d = j.data as PricingData; (["starter", "pro", "business"] as const).forEach((pid) => { const m = d[pid].price_monthly; d[pid].price_yearly = computeYearlyFromMonthly(m); }); setPricing(d); } if (j.stripe) setStripeStatus(j.stripe); if (j.meta?.env) setEnvMode(j.meta.env); } else if (res.status === 401) { showToast("err", "Session admin expirée. Reconnectez-vous."); } else { showToast("err", `Impossible de charger les tarifs (HTTP ${res.status}).`); } } catch { showToast("err", "Erreur réseau lors du chargement."); } finally { setLoading(false); } }, []); // eslint-disable-line react-hooks/exhaustive-deps useEffect(() => { loadPricing(); }, [loadPricing]); const parseApiError = async (res: Response): Promise => { const err: { detail?: string | Array<{ msg?: string; type?: string }>; message?: string; } = await res.json().catch(() => ({})); if (Array.isArray(err.detail)) { return err.detail.map((e) => e.msg || JSON.stringify(e)).join(" — ") || `Erreur HTTP ${res.status}`; } if (typeof err.detail === "string") return err.detail; if (err.message) return err.message; return `Erreur HTTP ${res.status}`; }; const savePricing = async () => { const token = useTranslationStore.getState().settings.adminToken; if (!token) { showToast("err", "Session admin absente. Reconnectez-vous sur /admin/login."); return; } for (const pid of ["starter", "pro", "business"] as const) { const m = pricing[pid].price_monthly; if (!Number.isFinite(m) || m < 0.01) { showToast( "err", `Prix mensuel invalide pour ${pricing[pid].name} : minimum 0,01 € (valeur actuelle : ${m}).`, ); return; } } setSaving(true); try { const body: Record = { starter: { price_monthly: pricing.starter.price_monthly, stripe_price_id_monthly: sanitizeStripePriceIdForSave( pricing.starter.stripe_price_id_monthly, ), stripe_price_id_yearly: sanitizeStripePriceIdForSave( pricing.starter.stripe_price_id_yearly, ), }, pro: { price_monthly: pricing.pro.price_monthly, stripe_price_id_monthly: sanitizeStripePriceIdForSave( pricing.pro.stripe_price_id_monthly, ), stripe_price_id_yearly: sanitizeStripePriceIdForSave( pricing.pro.stripe_price_id_yearly, ), }, business: { price_monthly: pricing.business.price_monthly, stripe_price_id_monthly: sanitizeStripePriceIdForSave( pricing.business.stripe_price_id_monthly, ), stripe_price_id_yearly: sanitizeStripePriceIdForSave( pricing.business.stripe_price_id_yearly, ), }, }; // Include Stripe keys only if filled in if (stripeKeys.secret) body.stripe_secret_key = stripeKeys.secret; if (stripeKeys.publishable) body.stripe_publishable_key = stripeKeys.publishable; if (stripeKeys.webhook) body.stripe_webhook_secret = stripeKeys.webhook; const res = await fetch(`${API_BASE}/api/v1/admin/pricing`, { method: "PUT", cache: "no-store", headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", }, body: JSON.stringify(body), }); if (res.ok) { showToast("ok", "✅ Tarifs enregistrés (fichier data/pricing_overrides.json). Les clés Stripe vont dans .env si renseignées."); setStripeKeys({ secret: "", publishable: "", webhook: "" }); loadPricing(); } else { const msg = await parseApiError(res); showToast("err", msg); } } catch { showToast("err", "Erreur réseau (vérifiez que le backend tourne et que l’URL API est correcte)."); } finally { setSaving(false); } }; const autoSetupStripe = async () => { const token = useTranslationStore.getState().settings.adminToken; if (!token) { showToast("err", "Session admin absente. Reconnectez-vous."); return; } setSetupLoading(true); setSetupResult(null); try { const res = await fetch(`${API_BASE}/api/v1/admin/pricing/setup-stripe`, { method: "POST", cache: "no-store", headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", }, }); const j = await res.json(); if (res.ok) { setSetupResult(j); if (j.errors?.length === 0) { showToast("ok", "✅ Produits Stripe créés et Price IDs enregistrés dans .env !"); } else if (j.data && Object.keys(j.data).length > 0) { showToast("ok", `⚠️ Partiellement réussi. ${j.errors?.length} erreur(s).`); } else { showToast("err", j.errors?.[0]?.error || j.message || "Échec de la configuration Stripe."); } 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 { showToast("err", j.message || "Erreur lors de la configuration Stripe."); setSetupResult(j); } } catch { showToast("err", "Erreur réseau."); } finally { setSetupLoading(false); } }; 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) => { setPricing((prev) => { const next = { ...prev[planId], [field]: value } as PlanConfig; if (field === "price_monthly") { const m = typeof value === "number" ? value : parseFloat(String(value)) || 0; next.price_yearly = computeYearlyFromMonthly(m); } return { ...prev, [planId]: next }; }); }; if (loading) { return (
); } return (
{/* Header */}

Pricing & Stripe

Gérez les prix des forfaits et la configuration Stripe. Enregistrer ici applique les tarifs tout de suite (API publique, checkout) —{" "} aucun redémarrage du serveur requis.

{/* Toast */} {toast && (
{toast.type === "ok" ? : } {toast.text}
)} {/* Stripe Status */} Statut Stripe {stripeStatus?.mode === "live" && ( ● LIVE )} {stripeStatus?.mode === "test" && ( ● TEST )} {stripeStatus?.mode === "unknown" && ( non configuré )} ENV={envMode} {/* BLOCKING warning if test mode in production */} {stripeStatus?.is_test_mode_in_production && (

⚠️ CLÉ STRIPE TEST EN PRODUCTION

ENV=production{" "} mais{" "} STRIPE_SECRET_KEY{" "} commence par sk_test_…. Les paiements en prod ne fonctionneront PAS — soit tu remplaces par{" "} sk_live_… dans .env.ionos, soit tu remets ENV=staging.

)}
{[ { label: "Clé secrète", ok: stripeStatus?.has_secret_key }, { label: "Clé publique", ok: stripeStatus?.has_publishable_key }, { label: "Webhook secret", ok: stripeStatus?.has_webhook_secret }, ].map(({ label, ok }) => (
{ok ? : } {label}
))}
{/* Auto-setup button (products + prices) */}

1. Produits & prix

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.

{setupResult && (
{Object.entries(setupResult.data || {}).map(([plan, info]: any) => (
{plan} — mensuel: {info.monthly_price_id}
))} {setupResult.errors?.map((e: any, i: number) => (
{e.plan}: {e.error}
))}
)}
{/* Auto-setup webhook */}

2. Webhook Stripe

Crée ou met à jour l'endpoint webhook dans Stripe pointant sur ce backend, et enregistre automatiquement le whsec_… dans le .env. L'URL est déduite de la requête courante (header Host + X-Forwarded-Proto).

{webhookResult?.data && (
ID: {webhookResult.data.id}
{webhookResult.data.url}
Events: {(webhookResult.data.enabled_events || []).length} abonné(s) {webhookResult.data.created ? " — endpoint créé" : " — endpoint mis à jour"} {webhookResult.data.secret_saved ? " — secret enregistré" : " — secret déjà connu"}
)}
{/* Manual Stripe keys */}

Clés Stripe (laisser vide pour garder les valeurs actuelles)

setStripeKeys(prev => ({ ...prev, secret: e.target.value }))} />
setStripeKeys(prev => ({ ...prev, publishable: e.target.value }))} />
setStripeKeys(prev => ({ ...prev, webhook: e.target.value }))} />

Obtenir vos clés sur dashboard.stripe.com/apikeys

{/* Plan pricing cards */}
{(["starter", "pro", "business"] as const).map((planId) => { const plan = pricing[planId]; const Icon = PLAN_ICONS[planId] || Sparkles; const hasMonthlyId = plan.stripe_price_id_monthly && !plan.stripe_price_id_monthly.includes("xxx"); const hasYearlyId = plan.stripe_price_id_yearly && !plan.stripe_price_id_yearly.includes("xxx"); return ( {plan.name} {hasMonthlyId && hasYearlyId ? ✓ Stripe OK : Price IDs manquants} {/* Prices */}
updatePlan(planId, "price_monthly", parseFloat(e.target.value) || 0)} className="h-8 text-sm" />

Facturation annuelle = 12 × mensuel × {YEARLY_DISCOUNT_FACTOR} (équivalent −{ANNUAL_DISCOUNT_PERCENT} % vs 12 mois au tarif mensuel). Le serveur impose cette règle à l’enregistrement et pour Stripe.

{/* Stripe Price IDs */}
updatePlan(planId, "stripe_price_id_monthly", e.target.value)} className={`h-8 text-xs font-mono ${hasMonthlyId ? "border-emerald-500/30" : "border-amber-500/30"}`} />
updatePlan(planId, "stripe_price_id_yearly", e.target.value)} className={`h-8 text-xs font-mono ${hasYearlyId ? "border-emerald-500/30" : "border-amber-500/30"}`} />
); })}
{/* Warning if Price IDs missing */} {["starter", "pro", "business"].some(p => { const plan = pricing[p as keyof PricingData]; return !plan.stripe_price_id_monthly || plan.stripe_price_id_monthly.includes("xxx"); }) && (

Price IDs Stripe manquants

Cliquez sur "Créer automatiquement" pour que le backend crée les produits dans Stripe et remplisse les IDs automatiquement. Ou entrez-les manuellement depuis votre{" "} tableau de bord Stripe .

)} {/* Save button */}
); }