From dc5cf250be0b342c2394272e2857b959f6704624 Mon Sep 17 00:00:00 2001 From: Hermes Date: Sat, 25 Apr 2026 17:39:08 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20Phase=204=20=E2=80=94=20real=20Stripe?= =?UTF-8?q?=20integration,=20feature=20gating,=20subscription=20flow?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 13 +++ client/src/App.tsx | 8 ++ client/src/pages/SubscriptionBlocked.tsx | 102 +++++++++++++++- client/src/pages/SubscriptionCancel.tsx | 40 +++++++ client/src/pages/SubscriptionPage.tsx | 142 +++++++++++++++++++---- client/src/pages/SubscriptionSuccess.tsx | 56 +++++++++ server/_core/index.ts | 28 +++++ server/routers.ts | 79 +++++++++++++ server/services/planLimits.ts | 133 +++++++++++++++++++++ 9 files changed, 572 insertions(+), 29 deletions(-) create mode 100644 client/src/pages/SubscriptionCancel.tsx create mode 100644 client/src/pages/SubscriptionSuccess.tsx create mode 100644 server/services/planLimits.ts diff --git a/.env.example b/.env.example index c517b05..e75c50c 100644 --- a/.env.example +++ b/.env.example @@ -19,6 +19,19 @@ NODE_ENV=development # In production this should match the public origin allowed by CORS. PUBLIC_BASE_URL= +# ─── Stripe ───────────────────────────────────────────────────────────────── +# All Stripe vars are OPTIONAL. If STRIPE_SECRET_KEY is not set, the app still +# runs and the subscription UI shows a friendly "not configured" notice. +STRIPE_SECRET_KEY= +STRIPE_WEBHOOK_SECRET= +STRIPE_BASIC_PRICE_ID= +STRIPE_PRO_PRICE_ID= + +# Client-side price IDs — must be exposed at build time via VITE_ prefix. +# Same values as STRIPE_BASIC_PRICE_ID / STRIPE_PRO_PRICE_ID. +VITE_STRIPE_BASIC_PRICE_ID= +VITE_STRIPE_PRO_PRICE_ID= + # ─── WhatsApp (Baileys) ───────────────────────────────────────────────────── # Persistent directory used to store Baileys auth credentials per clinic. # Must live on a Docker volume in production so sessions survive restarts. diff --git a/client/src/App.tsx b/client/src/App.tsx index e8d0621..f018bde 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -24,6 +24,8 @@ import ClinicSettings from "@/pages/ClinicSettings"; import ConsultationHistory from "@/pages/ConsultationHistory"; import WhatsAppSetup from "@/pages/WhatsAppSetup"; import SubscriptionBlocked from "@/pages/SubscriptionBlocked"; +import SubscriptionSuccess from "@/pages/SubscriptionSuccess"; +import SubscriptionCancel from "@/pages/SubscriptionCancel"; import AdminPanel from "@/pages/AdminPanel"; function ProtectedRoute({ children }: { children: React.ReactNode }) { @@ -94,6 +96,12 @@ export default function App() { + + + + + + {/* Admin */} diff --git a/client/src/pages/SubscriptionBlocked.tsx b/client/src/pages/SubscriptionBlocked.tsx index 15b25c2..bc3adca 100644 --- a/client/src/pages/SubscriptionBlocked.tsx +++ b/client/src/pages/SubscriptionBlocked.tsx @@ -2,17 +2,41 @@ import { Button } from "@/components/ui/button"; import { useLocation } from "wouter"; import { Helmet } from "react-helmet-async"; import { useTranslation } from "react-i18next"; -import { Lock, CreditCard, CheckCircle2 } from "lucide-react"; +import { Lock, CreditCard, CheckCircle2, Loader2, AlertTriangle } from "lucide-react"; +import { trpc } from "@/lib/trpc"; +import { toast } from "sonner"; export default function SubscriptionBlocked() { - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); const [, navigate] = useLocation(); + const planQuery = trpc.subscription.getCurrentPlan.useQuery(); + const checkQuery = trpc.subscription.check.useQuery(); + + const portalMutation = trpc.subscription.createPortalSession.useMutation({ + onSuccess: ({ url }) => { + window.location.href = url; + }, + onError: (e) => toast.error(e.message), + }); + const features = [ t("subscriptionBlocked.features.0"), t("subscriptionBlocked.features.1"), t("subscriptionBlocked.features.2"), t("subscriptionBlocked.features.3"), ]; + + const plan = planQuery.data; + const check = checkQuery.data; + const planName = plan?.plan ?? "—"; + const status = plan?.status ?? null; + const trialEnd = plan?.trialEndsAt ? new Date(plan.trialEndsAt) : null; + const periodEnd = plan?.currentPeriodEnd ? new Date(plan.currentPeriodEnd) : null; + const locale = i18n.language === "en" ? "en-US" : "fr-FR"; + const hasActiveSub = plan?.hasActiveSubscription ?? false; + const stripeReady = plan?.stripeConfigured ?? false; + const daysLeft = check?.daysRemaining ?? 0; + return (
@@ -29,20 +53,86 @@ export default function SubscriptionBlocked() {

{t("subscriptionBlocked.title")}

+ + {/* Live plan status */} +
+
+ Plan + {planName} +
+
+ Statut + + {status === "trialing" + ? t("subscription.trial") + : status === "active" + ? t("subscription.active") + : status ?? t("subscription.expired")} + +
+ {status === "trialing" && trialEnd && ( +
+ Fin d'essai + + {trialEnd.toLocaleDateString(locale)} + {daysLeft > 0 && ( + + ({t("subscription.daysCount", { count: daysLeft })}) + + )} + +
+ )} + {status === "active" && periodEnd && ( +
+ Renouvellement + {periodEnd.toLocaleDateString(locale)} +
+ )} +
+

{t("subscriptionBlocked.description")}

- {features.map(f => ( + {features.map((f) => (
{f}
))}
- + + {!stripeReady && ( +
+ +

+ Le paiement n'est pas configuré sur ce serveur. Contactez l'administrateur. +

+
+ )} + + {hasActiveSub ? ( + + ) : ( + + )} diff --git a/client/src/pages/SubscriptionCancel.tsx b/client/src/pages/SubscriptionCancel.tsx new file mode 100644 index 0000000..6a8cfc1 --- /dev/null +++ b/client/src/pages/SubscriptionCancel.tsx @@ -0,0 +1,40 @@ +import { useLocation } from "wouter"; +import { Helmet } from "react-helmet-async"; +import { XCircle } from "lucide-react"; +import { Button } from "@/components/ui/button"; + +export default function SubscriptionCancel() { + const [, navigate] = useLocation(); + + return ( +
+ + Paiement annulé — QueueMed + +
+
+ +
+

Paiement annulé

+

+ Aucun montant n'a été prélevé. Vous pouvez réessayer à tout moment ou + continuer à utiliser votre essai. +

+
+ + +
+
+
+ ); +} diff --git a/client/src/pages/SubscriptionPage.tsx b/client/src/pages/SubscriptionPage.tsx index a144fc2..195f08c 100644 --- a/client/src/pages/SubscriptionPage.tsx +++ b/client/src/pages/SubscriptionPage.tsx @@ -1,19 +1,39 @@ -import { useLocation } from "wouter"; import { Helmet } from "react-helmet-async"; import { useTranslation } from "react-i18next"; import { - CreditCard, Check, Sparkles, Clock, Loader2, AlertTriangle, - TrendingUp, Crown, Heart, + Check, Sparkles, Clock, Loader2, AlertTriangle, + TrendingUp, Crown, Heart, Settings, } from "lucide-react"; import { trpc } from "@/lib/trpc"; import { Button } from "@/components/ui/button"; import { toast } from "sonner"; +const STRIPE_BASIC_PRICE_ID = import.meta.env.VITE_STRIPE_BASIC_PRICE_ID as + | string + | undefined; +const STRIPE_PRO_PRICE_ID = import.meta.env.VITE_STRIPE_PRO_PRICE_ID as + | string + | undefined; + export default function SubscriptionPage() { const { t, i18n } = useTranslation(); - const [, navigate] = useLocation(); const subQuery = trpc.subscription.get.useQuery(); const checkQuery = trpc.subscription.check.useQuery(); + const planQuery = trpc.subscription.getCurrentPlan.useQuery(); + + const checkoutMutation = trpc.subscription.createCheckoutSession.useMutation({ + onSuccess: ({ url }) => { + window.location.href = url; + }, + onError: (e) => toast.error(e.message), + }); + + const portalMutation = trpc.subscription.createPortalSession.useMutation({ + onSuccess: ({ url }) => { + window.location.href = url; + }, + onError: (e) => toast.error(e.message), + }); const PLANS = [ { @@ -30,6 +50,7 @@ export default function SubscriptionPage() { ], icon: Sparkles, color: "from-slate-500 to-slate-600", + priceId: undefined as string | undefined, }, { plan: "basic" as const, @@ -47,6 +68,7 @@ export default function SubscriptionPage() { icon: TrendingUp, color: "from-emerald-500 to-teal-500", highlighted: true, + priceId: STRIPE_BASIC_PRICE_ID, }, { plan: "pro" as const, @@ -63,19 +85,46 @@ export default function SubscriptionPage() { ], icon: Crown, color: "from-violet-500 to-fuchsia-500", + priceId: STRIPE_PRO_PRICE_ID, }, ]; const sub = subQuery.data; const check = checkQuery.data; + const plan = planQuery.data; - const handleSubscribe = (plan: "basic" | "pro") => { - toast.info(t("subscription.toastRedirect", { plan }), { - description: t("subscription.toastRedirectDescription"), - }); + const stripeReady = plan?.stripeConfigured ?? false; + const hasActiveSub = plan?.hasActiveSubscription ?? false; + const checkoutBusy = checkoutMutation.isPending; + const portalBusy = portalMutation.isPending; + + const handleSubscribe = (planKey: "basic" | "pro", priceId: string | undefined) => { + if (!stripeReady) { + toast.error( + "Le paiement Stripe n'est pas configuré sur ce serveur. Contactez l'administrateur." + ); + return; + } + if (!priceId) { + toast.error( + `Identifiant de prix manquant pour le plan ${planKey}. Configurez VITE_STRIPE_${planKey === "basic" ? "BASIC" : "PRO"}_PRICE_ID.` + ); + return; + } + checkoutMutation.mutate({ priceId }); }; - if (subQuery.isLoading) { + const handleManage = () => { + if (!stripeReady) { + toast.error( + "Le paiement Stripe n'est pas configuré sur ce serveur. Contactez l'administrateur." + ); + return; + } + portalMutation.mutate(); + }; + + if (subQuery.isLoading || planQuery.isLoading) { return (
@@ -100,6 +149,20 @@ export default function SubscriptionPage() {

{t("subscription.subtitle")}

+ {!stripeReady && ( +
+ +
+ Paiement Stripe non configuré.{" "} + L'application fonctionne normalement, mais les abonnements payants ne sont + pas activés sur ce serveur. Définissez STRIPE_SECRET_KEY et{" "} + STRIPE_WEBHOOK_SECRET côté serveur, ainsi que les IDs de prix + côté client (VITE_STRIPE_BASIC_PRICE_ID,{" "} + VITE_STRIPE_PRO_PRICE_ID). +
+
+ )} + {/* Current status */}
- {(isTrialing || expired) && ( - - )} +
+ {hasActiveSub && ( + + )} + {(isTrialing || expired) && ( + + )} +
{isTrialing && daysLeft > 0 && ( @@ -188,6 +273,7 @@ export default function SubscriptionPage() { {PLANS.map((p) => { const Icon = p.icon; const isCurrent = sub?.plan === p.plan; + const canCheckout = p.plan !== "trial" && stripeReady && !!p.priceId; return (
{isCurrent ? t("subscription.currentPlanLabel") : t("subscription.automaticTrial")} - ) : isCurrent ? ( + ) : isCurrent && hasActiveSub ? ( ) : ( )} diff --git a/client/src/pages/SubscriptionSuccess.tsx b/client/src/pages/SubscriptionSuccess.tsx new file mode 100644 index 0000000..6dd1beb --- /dev/null +++ b/client/src/pages/SubscriptionSuccess.tsx @@ -0,0 +1,56 @@ +import { useEffect } from "react"; +import { useLocation } from "wouter"; +import { Helmet } from "react-helmet-async"; +import { CheckCircle2, Sparkles } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { trpc } from "@/lib/trpc"; + +export default function SubscriptionSuccess() { + const [, navigate] = useLocation(); + const utils = trpc.useUtils(); + + useEffect(() => { + // Refresh subscription data so the dashboard reflects the new plan immediately. + utils.subscription.get.invalidate(); + utils.subscription.check.invalidate(); + utils.subscription.getCurrentPlan.invalidate(); + + const t = setTimeout(() => navigate("/dashboard/subscription"), 5000); + return () => clearTimeout(t); + }, [navigate, utils]); + + return ( +
+ + Paiement confirmé — QueueMed + +
+
+ +
+

Merci pour votre abonnement !

+

+ Votre paiement a été confirmé avec succès. +

+

+ + Redirection automatique dans quelques secondes… +

+
+ + +
+
+
+ ); +} diff --git a/server/_core/index.ts b/server/_core/index.ts index e6e9a57..4b933b4 100644 --- a/server/_core/index.ts +++ b/server/_core/index.ts @@ -161,6 +161,34 @@ async function bootstrap() { ); app.use("/api", globalLimiter); + // ── Stripe webhook (RAW body, must come BEFORE express.json) ───────────── + app.post( + "/api/stripe/webhook", + express.raw({ type: "application/json", limit: "1mb" }), + async (req, res) => { + // If Stripe is not configured, silently acknowledge so the route never 500s. + if (!isStripeConfigured()) { + res.status(200).json({ received: true, configured: false }); + return; + } + const signature = req.headers["stripe-signature"]; + if (typeof signature !== "string") { + res.status(400).json({ error: "Missing stripe-signature header" }); + return; + } + try { + const event = verifyAndConstructEvent(req.body as Buffer, signature); + await handleStripeWebhook(event); + res.status(200).json({ received: true }); + } catch (err) { + // eslint-disable-next-line no-console + console.error("[stripe] webhook error:", err); + const message = err instanceof Error ? err.message : "Webhook error"; + res.status(400).json({ error: message }); + } + } + ); + // ── Body / cookies / auth ──────────────────────────────────────────────── app.use(express.json({ limit: "1mb" })); app.use(cookieParser()); diff --git a/server/routers.ts b/server/routers.ts index a7eb2a0..cfea5e3 100644 --- a/server/routers.ts +++ b/server/routers.ts @@ -84,6 +84,12 @@ import { formatWeeklySchedule, type OpeningHours, } from "../shared/openingHours.js"; +import { + createCheckoutSession, + createPortalSession, + isStripeConfigured, +} from "./services/stripe.js"; +import { checkPlanLimit, getPlanLimitsForUser } from "./services/planLimits.js"; // ─── Socket.io accessor ────────────────────────────────────────────────────── function getIo(): SocketIOServer | null { @@ -335,6 +341,10 @@ const clinicRouter = router({ }) ) .mutation(async ({ input, ctx }) => { + const limit = await checkPlanLimit(ctx.user.id, "maxClinics"); + if (!limit.ok) { + throw new TRPCError({ code: "FORBIDDEN", message: limit.reason }); + } const result = await createClinic(ctx.user.id, { name: input.name, address: input.address ?? null, @@ -469,6 +479,10 @@ const clinicRouter = router({ if (!clinic || clinic.userId !== ctx.user.id) { throw new TRPCError({ code: "NOT_FOUND", message: "Cabinet introuvable" }); } + const limit = await checkPlanLimit(ctx.user.id, "multiPractitioner"); + if (!limit.ok) { + throw new TRPCError({ code: "FORBIDDEN", message: limit.reason }); + } const memberUser = await getUserByEmail(input.email.toLowerCase()); if (!memberUser) { throw new TRPCError({ @@ -657,6 +671,12 @@ const queueRouter = router({ if (!clinic || !clinic.isActive) { throw new TRPCError({ code: "NOT_FOUND", message: "Cabinet introuvable" }); } + const dailyLimit = await checkPlanLimit(clinic.userId, "maxQueueEntriesPerDay", { + clinicId: clinic.id, + }); + if (!dailyLimit.ok) { + throw new TRPCError({ code: "FORBIDDEN", message: dailyLimit.reason }); + } if (clinic.qrToken !== input.qrToken) { throw new TRPCError({ code: "FORBIDDEN", @@ -1253,6 +1273,65 @@ const subscriptionRouter = router({ daysRemaining, }; }), + + getCurrentPlan: protectedProcedure.query(async ({ ctx }) => { + const sub = await getSubscription(ctx.user.id); + const { plan, limits } = await getPlanLimitsForUser(ctx.user.id); + const stripeReady = isStripeConfigured(); + return { + plan, + status: sub?.status ?? null, + trialEndsAt: sub?.trialEndsAt ?? null, + currentPeriodEnd: sub?.currentPeriodEnd ?? null, + hasStripeCustomer: Boolean(sub?.stripeCustomerId), + hasActiveSubscription: Boolean(sub?.stripeSubscriptionId), + stripeConfigured: stripeReady, + limits: { + maxClinics: + limits.maxClinics === Infinity ? null : limits.maxClinics, + maxQueueEntriesPerDay: + limits.maxQueueEntriesPerDay === Infinity + ? null + : limits.maxQueueEntriesPerDay, + multiPractitioner: limits.multiPractitioner, + analyticsExport: limits.analyticsExport, + }, + }; + }), + + createCheckoutSession: protectedProcedure + .input(z.object({ priceId: z.string().min(1).max(255) })) + .mutation(async ({ input, ctx }) => { + if (!isStripeConfigured()) { + throw new TRPCError({ + code: "PRECONDITION_FAILED", + message: "Le paiement n'est pas encore configuré. Contactez l'administrateur.", + }); + } + try { + const { url } = await createCheckoutSession(ctx.user.id, input.priceId); + return { url }; + } catch (err) { + const message = err instanceof Error ? err.message : "Stripe Checkout failed"; + throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message }); + } + }), + + createPortalSession: protectedProcedure.mutation(async ({ ctx }) => { + if (!isStripeConfigured()) { + throw new TRPCError({ + code: "PRECONDITION_FAILED", + message: "Le paiement n'est pas encore configuré. Contactez l'administrateur.", + }); + } + try { + const { url } = await createPortalSession(ctx.user.id); + return { url }; + } catch (err) { + const message = err instanceof Error ? err.message : "Stripe Portal failed"; + throw new TRPCError({ code: "BAD_REQUEST", message }); + } + }), }); // ─── WhatsApp router ───────────────────────────────────────────────────────── diff --git a/server/services/planLimits.ts b/server/services/planLimits.ts new file mode 100644 index 0000000..e5a2339 --- /dev/null +++ b/server/services/planLimits.ts @@ -0,0 +1,133 @@ +import { and, eq, gte } from "drizzle-orm"; +import { getDb, getSubscription } from "../db.js"; +import { clinics, queueEntries } from "../schema.js"; +import { sql } from "drizzle-orm"; + +export type PlanFeature = + | "maxClinics" + | "maxQueueEntriesPerDay" + | "multiPractitioner" + | "analyticsExport"; + +type PlanLimits = { + maxClinics: number; + maxQueueEntriesPerDay: number; + multiPractitioner: boolean; + analyticsExport: boolean; +}; + +// trial == basic level access during trial period; pro lifts everything. +export const PLAN_LIMITS: Record<"trial" | "basic" | "pro", PlanLimits> = { + trial: { + maxClinics: 1, + maxQueueEntriesPerDay: 50, + multiPractitioner: false, + analyticsExport: false, + }, + basic: { + maxClinics: 1, + maxQueueEntriesPerDay: 200, + multiPractitioner: false, + analyticsExport: true, + }, + pro: { + maxClinics: Infinity, + maxQueueEntriesPerDay: Infinity, + multiPractitioner: true, + analyticsExport: true, + }, +}; + +export type PlanLimitCheck = + | { ok: true } + | { ok: false; reason: string; feature: PlanFeature }; + +async function getUserPlan(userId: number): Promise<"trial" | "basic" | "pro"> { + const sub = await getSubscription(userId); + return sub?.plan ?? "trial"; +} + +function limits(plan: "trial" | "basic" | "pro"): PlanLimits { + return PLAN_LIMITS[plan]; +} + +async function countClinicsForUser(userId: number): Promise { + const db = await getDb(); + const rows = await db + .select({ count: sql`COUNT(*)` }) + .from(clinics) + .where(eq(clinics.userId, userId)); + return Number(rows[0]?.count ?? 0); +} + +async function countQueueEntriesTodayForClinic(clinicId: number): Promise { + const db = await getDb(); + const startOfDay = new Date(); + startOfDay.setHours(0, 0, 0, 0); + const rows = await db + .select({ count: sql`COUNT(*)` }) + .from(queueEntries) + .where( + and(eq(queueEntries.clinicId, clinicId), gte(queueEntries.joinedAt, startOfDay)) + ); + return Number(rows[0]?.count ?? 0); +} + +const FEATURE_MESSAGES: Record = { + maxClinics: "Limite de cabinets atteinte : passez au plan Pro pour en créer plus.", + maxQueueEntriesPerDay: + "Limite quotidienne de patients atteinte : passez au plan Pro pour des inscriptions illimitées.", + multiPractitioner: + "La gestion multi-praticiens est réservée au plan Pro. Mettez à niveau pour ajouter des praticiens.", + analyticsExport: + "L'export des statistiques est réservé aux plans payants. Mettez à niveau pour exporter vos données.", +}; + +export async function checkPlanLimit( + userId: number, + feature: PlanFeature, + context: { clinicId?: number } = {} +): Promise { + const plan = await getUserPlan(userId); + const max = limits(plan); + + switch (feature) { + case "maxClinics": { + if (max.maxClinics === Infinity) return { ok: true }; + const current = await countClinicsForUser(userId); + if (current >= max.maxClinics) { + return { ok: false, feature, reason: FEATURE_MESSAGES.maxClinics }; + } + return { ok: true }; + } + case "maxQueueEntriesPerDay": { + if (max.maxQueueEntriesPerDay === Infinity) return { ok: true }; + if (!context.clinicId) return { ok: true }; + const today = await countQueueEntriesTodayForClinic(context.clinicId); + if (today >= max.maxQueueEntriesPerDay) { + return { + ok: false, + feature, + reason: FEATURE_MESSAGES.maxQueueEntriesPerDay, + }; + } + return { ok: true }; + } + case "multiPractitioner": { + if (max.multiPractitioner) return { ok: true }; + return { ok: false, feature, reason: FEATURE_MESSAGES.multiPractitioner }; + } + case "analyticsExport": { + if (max.analyticsExport) return { ok: true }; + return { ok: false, feature, reason: FEATURE_MESSAGES.analyticsExport }; + } + } +} + +export async function getPlanLimitsForUser(userId: number): Promise<{ + plan: "trial" | "basic" | "pro"; + limits: PlanLimits; +}> { + const plan = await getUserPlan(userId); + return { plan, limits: limits(plan) }; +}