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.
727 lines
29 KiB
TypeScript
727 lines
29 KiB
TypeScript
"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<string, React.ElementType> = {
|
||
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<PricingData>(defaultPricingData);
|
||
const [stripeStatus, setStripeStatus] = useState<StripeStatus | null>(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<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 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<string> => {
|
||
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<string, unknown> = {
|
||
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 (
|
||
<div className="flex items-center justify-center py-16">
|
||
<Loader2 className="w-8 h-8 animate-spin text-muted-foreground" />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className="space-y-6 max-w-5xl">
|
||
{/* Header */}
|
||
<div className="flex items-center gap-3">
|
||
<div className="w-10 h-10 bg-emerald-600/20 rounded-lg flex items-center justify-center">
|
||
<CreditCard className="w-5 h-5 text-emerald-400" />
|
||
</div>
|
||
<div>
|
||
<h1 className="text-xl font-semibold text-foreground">Pricing & Stripe</h1>
|
||
<p className="text-sm text-muted-foreground">
|
||
Gérez les prix des forfaits et la configuration Stripe. Enregistrer ici applique les tarifs tout de suite (API publique, checkout) —{" "}
|
||
<strong className="text-foreground/90">aucun redémarrage du serveur requis</strong>.
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Toast */}
|
||
{toast && (
|
||
<div className={`flex items-start gap-3 p-4 rounded-xl border text-sm ${
|
||
toast.type === "ok"
|
||
? "bg-emerald-900/20 border-emerald-600/30 text-emerald-300"
|
||
: "bg-red-900/20 border-red-600/30 text-red-300"
|
||
}`}>
|
||
{toast.type === "ok" ? <CheckCircle className="w-4 h-4 flex-shrink-0 mt-0.5" /> : <XCircle className="w-4 h-4 flex-shrink-0 mt-0.5" />}
|
||
<span className="flex-1">{toast.text}</span>
|
||
<button onClick={() => setToast(null)}>✕</button>
|
||
</div>
|
||
)}
|
||
|
||
{/* Stripe Status */}
|
||
<Card>
|
||
<CardHeader className="pb-3">
|
||
<CardTitle className="text-base flex items-center gap-2">
|
||
<Sparkles className="w-4 h-4 text-violet-400" />
|
||
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>
|
||
</CardHeader>
|
||
<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">
|
||
{[
|
||
{ 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 }) => (
|
||
<div key={label} className={`rounded-lg border p-3 flex items-center gap-2 ${
|
||
ok ? "border-emerald-600/30 bg-emerald-900/10" : "border-red-600/30 bg-red-900/10"
|
||
}`}>
|
||
{ok
|
||
? <CheckCircle className="w-4 h-4 text-emerald-400 flex-shrink-0" />
|
||
: <XCircle className="w-4 h-4 text-red-400 flex-shrink-0" />}
|
||
<span className="text-sm">{label}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
<Separator />
|
||
|
||
{/* Auto-setup button (products + prices) */}
|
||
<div className="flex items-start gap-4">
|
||
<div className="flex-1">
|
||
<p className="text-sm font-medium text-foreground">1. Produits & prix</p>
|
||
<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.
|
||
</p>
|
||
{setupResult && (
|
||
<div className="mt-2 p-3 rounded-lg bg-secondary/40 text-xs space-y-1">
|
||
{Object.entries(setupResult.data || {}).map(([plan, info]: any) => (
|
||
<div key={plan} className="flex items-center gap-2">
|
||
<CheckCircle className="w-3 h-3 text-emerald-400" />
|
||
<span className="capitalize font-medium">{plan}</span>
|
||
<span className="text-muted-foreground">— mensuel: <code>{info.monthly_price_id}</code></span>
|
||
</div>
|
||
))}
|
||
{setupResult.errors?.map((e: any, i: number) => (
|
||
<div key={i} className="flex items-center gap-2 text-red-400">
|
||
<XCircle className="w-3 h-3" />
|
||
<span>{e.plan}: {e.error}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
<Button
|
||
onClick={autoSetupStripe}
|
||
disabled={setupLoading || !stripeStatus?.has_secret_key}
|
||
className="bg-violet-600 hover:bg-violet-500 gap-2 flex-shrink-0"
|
||
>
|
||
{setupLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : <RefreshCw className="w-4 h-4" />}
|
||
{setupLoading ? "Configuration…" : "Créer automatiquement"}
|
||
</Button>
|
||
</div>
|
||
|
||
<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 */}
|
||
<div>
|
||
<p className="text-sm font-medium text-foreground mb-3">Clés Stripe (laisser vide pour garder les valeurs actuelles)</p>
|
||
<div className="grid gap-3">
|
||
<div className="space-y-1.5">
|
||
<Label htmlFor="stripe-secret" className="text-xs text-muted-foreground">Clé secrète (sk_test_... ou sk_live_...)</Label>
|
||
<Input
|
||
id="stripe-secret"
|
||
type="password"
|
||
placeholder={stripeStatus?.has_secret_key ? "Clé déjà configurée — laisser vide pour garder" : "sk_test_..."}
|
||
value={stripeKeys.secret}
|
||
onChange={e => setStripeKeys(prev => ({ ...prev, secret: e.target.value }))}
|
||
/>
|
||
</div>
|
||
<div className="space-y-1.5">
|
||
<Label htmlFor="stripe-pub" className="text-xs text-muted-foreground">Clé publique (pk_test_... ou pk_live_...)</Label>
|
||
<Input
|
||
id="stripe-pub"
|
||
placeholder={stripeStatus?.has_publishable_key ? "Clé déjà configurée" : "pk_test_..."}
|
||
value={stripeKeys.publishable}
|
||
onChange={e => setStripeKeys(prev => ({ ...prev, publishable: e.target.value }))}
|
||
/>
|
||
</div>
|
||
<div className="space-y-1.5">
|
||
<Label htmlFor="stripe-webhook" className="text-xs text-muted-foreground">Webhook secret (whsec_...)</Label>
|
||
<Input
|
||
id="stripe-webhook"
|
||
type="password"
|
||
placeholder={stripeStatus?.has_webhook_secret ? "Secret déjà configuré" : "whsec_..."}
|
||
value={stripeKeys.webhook}
|
||
onChange={e => setStripeKeys(prev => ({ ...prev, webhook: e.target.value }))}
|
||
/>
|
||
</div>
|
||
</div>
|
||
<p className="text-xs text-muted-foreground mt-2 flex items-center gap-1">
|
||
<ExternalLink className="w-3 h-3" />
|
||
<a href="https://dashboard.stripe.com/apikeys" target="_blank" rel="noreferrer" className="underline hover:text-foreground">
|
||
Obtenir vos clés sur dashboard.stripe.com/apikeys
|
||
</a>
|
||
</p>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
{/* Plan pricing cards */}
|
||
<div className="grid gap-4 md:grid-cols-3">
|
||
{(["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 (
|
||
<Card key={planId} className={`${
|
||
planId === "pro" ? "border-violet-500/30" :
|
||
planId === "business" ? "border-emerald-500/30" : ""
|
||
}`}>
|
||
<CardHeader className="pb-3">
|
||
<CardTitle className="text-sm flex items-center gap-2">
|
||
<Icon className={`w-4 h-4 ${
|
||
planId === "pro" ? "text-violet-400" :
|
||
planId === "business" ? "text-emerald-400" : "text-blue-400"
|
||
}`} />
|
||
{plan.name}
|
||
{hasMonthlyId && hasYearlyId
|
||
? <Badge className="ml-auto text-xs bg-emerald-900/30 text-emerald-300 border-emerald-500/30">✓ Stripe OK</Badge>
|
||
: <Badge variant="outline" className="ml-auto text-xs text-amber-400 border-amber-500/30">Price IDs manquants</Badge>}
|
||
</CardTitle>
|
||
</CardHeader>
|
||
<CardContent className="space-y-3">
|
||
{/* Prices */}
|
||
<div className="grid grid-cols-2 gap-2">
|
||
<div className="space-y-1">
|
||
<Label className="text-xs text-muted-foreground">Prix mensuel (€)</Label>
|
||
<Input
|
||
type="number"
|
||
min="0"
|
||
step="0.01"
|
||
value={plan.price_monthly}
|
||
onChange={e => updatePlan(planId, "price_monthly", parseFloat(e.target.value) || 0)}
|
||
className="h-8 text-sm"
|
||
/>
|
||
</div>
|
||
<div className="space-y-1">
|
||
<Label className="text-xs text-muted-foreground">
|
||
Prix annuel (€) — auto (−{ANNUAL_DISCOUNT_PERCENT} %)
|
||
</Label>
|
||
<Input
|
||
type="number"
|
||
readOnly
|
||
tabIndex={-1}
|
||
value={plan.price_yearly}
|
||
className="h-8 text-sm bg-muted/40 cursor-not-allowed"
|
||
aria-readonly
|
||
/>
|
||
</div>
|
||
</div>
|
||
<p className="text-[10px] text-muted-foreground leading-tight">
|
||
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.
|
||
</p>
|
||
|
||
<Separator />
|
||
|
||
{/* Stripe Price IDs */}
|
||
<div className="space-y-2">
|
||
<div className="space-y-1">
|
||
<Label className="text-xs text-muted-foreground flex items-center gap-1">
|
||
<KeyRound className="w-3 h-3" /> Price ID mensuel
|
||
</Label>
|
||
<Input
|
||
placeholder="price_..."
|
||
value={plan.stripe_price_id_monthly}
|
||
onChange={e => 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"}`}
|
||
/>
|
||
</div>
|
||
<div className="space-y-1">
|
||
<Label className="text-xs text-muted-foreground flex items-center gap-1">
|
||
<KeyRound className="w-3 h-3" /> Price ID annuel
|
||
</Label>
|
||
<Input
|
||
placeholder="price_..."
|
||
value={plan.stripe_price_id_yearly}
|
||
onChange={e => 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"}`}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
);
|
||
})}
|
||
</div>
|
||
|
||
{/* 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");
|
||
}) && (
|
||
<div className="flex items-start gap-3 p-4 rounded-xl border border-amber-500/30 bg-amber-900/10 text-sm">
|
||
<AlertTriangle className="w-4 h-4 text-amber-400 flex-shrink-0 mt-0.5" />
|
||
<div>
|
||
<p className="text-amber-300 font-medium">Price IDs Stripe manquants</p>
|
||
<p className="text-amber-400/80 text-xs mt-1">
|
||
Cliquez sur <strong>"Créer automatiquement"</strong> pour que le backend crée les produits dans Stripe et remplisse les IDs automatiquement. Ou entrez-les manuellement depuis votre{" "}
|
||
<a href="https://dashboard.stripe.com/products" target="_blank" rel="noreferrer" className="underline">
|
||
tableau de bord Stripe
|
||
</a>.
|
||
</p>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Save button */}
|
||
<div className="flex justify-end">
|
||
<Button onClick={savePricing} disabled={saving} size="lg" className="gap-2">
|
||
{saving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
|
||
{saving ? "Sauvegarde…" : "Sauvegarder la configuration"}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|