feat: Phase 4 — real Stripe integration, feature gating, subscription flow
This commit is contained in:
parent
f93690610b
commit
dc5cf250be
9 changed files with 572 additions and 29 deletions
13
.env.example
13
.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.
|
||||
|
|
|
|||
|
|
@ -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() {
|
|||
<Route path="/dashboard/subscription-blocked">
|
||||
<ProtectedRoute><SubscriptionBlocked /></ProtectedRoute>
|
||||
</Route>
|
||||
<Route path="/subscription/success">
|
||||
<ProtectedRoute><SubscriptionSuccess /></ProtectedRoute>
|
||||
</Route>
|
||||
<Route path="/subscription/cancel">
|
||||
<ProtectedRoute><SubscriptionCancel /></ProtectedRoute>
|
||||
</Route>
|
||||
|
||||
{/* Admin */}
|
||||
<Route path="/admin">
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div className="min-h-screen flex items-center justify-center p-4">
|
||||
<Helmet>
|
||||
|
|
@ -29,20 +53,86 @@ export default function SubscriptionBlocked() {
|
|||
<Lock className="w-8 h-8 text-destructive" />
|
||||
</div>
|
||||
<h1 className="font-display text-2xl font-bold mb-3">{t("subscriptionBlocked.title")}</h1>
|
||||
|
||||
{/* Live plan status */}
|
||||
<div className="rounded-2xl bg-slate-50 border border-slate-200/80 px-4 py-3 text-left mb-5 text-sm">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="text-slate-500">Plan</span>
|
||||
<span className="font-semibold capitalize">{planName}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="text-slate-500">Statut</span>
|
||||
<span className="font-semibold">
|
||||
{status === "trialing"
|
||||
? t("subscription.trial")
|
||||
: status === "active"
|
||||
? t("subscription.active")
|
||||
: status ?? t("subscription.expired")}
|
||||
</span>
|
||||
</div>
|
||||
{status === "trialing" && trialEnd && (
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-slate-500">Fin d'essai</span>
|
||||
<span className="font-semibold">
|
||||
{trialEnd.toLocaleDateString(locale)}
|
||||
{daysLeft > 0 && (
|
||||
<span className="text-slate-500 ml-1">
|
||||
({t("subscription.daysCount", { count: daysLeft })})
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{status === "active" && periodEnd && (
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-slate-500">Renouvellement</span>
|
||||
<span className="font-semibold">{periodEnd.toLocaleDateString(locale)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="text-muted-foreground text-sm mb-8 leading-relaxed">
|
||||
{t("subscriptionBlocked.description")}
|
||||
</p>
|
||||
<div className="space-y-3 mb-8 text-left">
|
||||
{features.map(f => (
|
||||
{features.map((f) => (
|
||||
<div key={f} className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<CheckCircle2 className="w-4 h-4 text-primary flex-shrink-0" />
|
||||
{f}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<Button onClick={() => navigate("/dashboard/subscription")} className="w-full bg-primary text-primary-foreground hover:bg-primary/90 glow-teal h-12 font-semibold">
|
||||
<CreditCard className="w-4 h-4 mr-2" /> {t("subscriptionBlocked.cta")}
|
||||
</Button>
|
||||
|
||||
{!stripeReady && (
|
||||
<div className="rounded-xl border border-amber-200 bg-amber-50/80 px-3 py-2 mb-4 flex items-start gap-2 text-left">
|
||||
<AlertTriangle className="w-4 h-4 text-amber-600 mt-0.5 flex-shrink-0" />
|
||||
<p className="text-xs text-amber-900">
|
||||
Le paiement n'est pas configuré sur ce serveur. Contactez l'administrateur.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasActiveSub ? (
|
||||
<Button
|
||||
onClick={() => portalMutation.mutate()}
|
||||
disabled={portalMutation.isPending || !stripeReady}
|
||||
className="w-full bg-primary text-primary-foreground hover:bg-primary/90 glow-teal h-12 font-semibold"
|
||||
>
|
||||
{portalMutation.isPending ? (
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<CreditCard className="w-4 h-4 mr-2" />
|
||||
)}
|
||||
Gérer l'abonnement
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
onClick={() => navigate("/dashboard/subscription")}
|
||||
className="w-full bg-primary text-primary-foreground hover:bg-primary/90 glow-teal h-12 font-semibold"
|
||||
>
|
||||
<CreditCard className="w-4 h-4 mr-2" /> {t("subscriptionBlocked.cta")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
40
client/src/pages/SubscriptionCancel.tsx
Normal file
40
client/src/pages/SubscriptionCancel.tsx
Normal file
|
|
@ -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 (
|
||||
<div className="min-h-screen flex items-center justify-center p-4">
|
||||
<Helmet>
|
||||
<title>Paiement annulé — QueueMed</title>
|
||||
</Helmet>
|
||||
<div className="glass-card rounded-3xl p-10 text-center max-w-md">
|
||||
<div className="w-16 h-16 rounded-2xl bg-amber-100 border border-amber-200 flex items-center justify-center mx-auto mb-6">
|
||||
<XCircle className="w-9 h-9 text-amber-600" />
|
||||
</div>
|
||||
<h1 className="font-bold text-2xl mb-3">Paiement annulé</h1>
|
||||
<p className="text-slate-600 text-sm mb-8">
|
||||
Aucun montant n'a été prélevé. Vous pouvez réessayer à tout moment ou
|
||||
continuer à utiliser votre essai.
|
||||
</p>
|
||||
<div className="flex flex-col sm:flex-row gap-2 justify-center">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => navigate("/dashboard")}
|
||||
>
|
||||
Tableau de bord
|
||||
</Button>
|
||||
<Button
|
||||
variant="gradient"
|
||||
onClick={() => navigate("/dashboard/subscription")}
|
||||
>
|
||||
Retour aux plans
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 (
|
||||
<div className="container py-20 flex justify-center">
|
||||
<Loader2 className="w-8 h-8 text-emerald-500 animate-spin" />
|
||||
|
|
@ -100,6 +149,20 @@ export default function SubscriptionPage() {
|
|||
<p className="text-slate-600">{t("subscription.subtitle")}</p>
|
||||
</div>
|
||||
|
||||
{!stripeReady && (
|
||||
<div className="rounded-2xl border border-amber-200 bg-amber-50/80 px-5 py-4 mb-6 flex items-start gap-3">
|
||||
<AlertTriangle className="w-5 h-5 text-amber-600 mt-0.5 flex-shrink-0" />
|
||||
<div className="text-sm text-amber-900">
|
||||
<strong className="font-semibold">Paiement Stripe non configuré.</strong>{" "}
|
||||
L'application fonctionne normalement, mais les abonnements payants ne sont
|
||||
pas activés sur ce serveur. Définissez <code>STRIPE_SECRET_KEY</code> et{" "}
|
||||
<code>STRIPE_WEBHOOK_SECRET</code> côté serveur, ainsi que les IDs de prix
|
||||
côté client (<code>VITE_STRIPE_BASIC_PRICE_ID</code>,{" "}
|
||||
<code>VITE_STRIPE_PRO_PRICE_ID</code>).
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Current status */}
|
||||
<div
|
||||
className={`glass-card-strong rounded-3xl p-8 mb-8 relative overflow-hidden ${
|
||||
|
|
@ -154,16 +217,38 @@ export default function SubscriptionPage() {
|
|||
)}
|
||||
</div>
|
||||
|
||||
{(isTrialing || expired) && (
|
||||
<Button
|
||||
variant="gradient"
|
||||
size="lg"
|
||||
onClick={() => handleSubscribe("basic")}
|
||||
>
|
||||
<Sparkles className="w-4 h-4 mr-2" />
|
||||
{t("subscription.subscribeNow")}
|
||||
</Button>
|
||||
)}
|
||||
<div className="flex flex-col sm:flex-row gap-2">
|
||||
{hasActiveSub && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="lg"
|
||||
onClick={handleManage}
|
||||
disabled={portalBusy}
|
||||
>
|
||||
{portalBusy ? (
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<Settings className="w-4 h-4 mr-2" />
|
||||
)}
|
||||
Gérer l'abonnement
|
||||
</Button>
|
||||
)}
|
||||
{(isTrialing || expired) && (
|
||||
<Button
|
||||
variant="gradient"
|
||||
size="lg"
|
||||
onClick={() => handleSubscribe("basic", STRIPE_BASIC_PRICE_ID)}
|
||||
disabled={checkoutBusy || !stripeReady}
|
||||
>
|
||||
{checkoutBusy ? (
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<Sparkles className="w-4 h-4 mr-2" />
|
||||
)}
|
||||
{t("subscription.subscribeNow")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{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 (
|
||||
<div
|
||||
key={p.plan}
|
||||
|
|
@ -237,13 +323,19 @@ export default function SubscriptionPage() {
|
|||
>
|
||||
{isCurrent ? t("subscription.currentPlanLabel") : t("subscription.automaticTrial")}
|
||||
</Button>
|
||||
) : isCurrent ? (
|
||||
) : isCurrent && hasActiveSub ? (
|
||||
<Button
|
||||
variant={p.highlighted ? "default" : "outline"}
|
||||
className={`w-full ${p.highlighted ? "bg-white text-emerald-700 hover:bg-emerald-50" : ""}`}
|
||||
disabled
|
||||
onClick={handleManage}
|
||||
disabled={portalBusy}
|
||||
>
|
||||
{t("subscription.currentPlanLabel")}
|
||||
{portalBusy ? (
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<Settings className="w-4 h-4 mr-2" />
|
||||
)}
|
||||
Gérer l'abonnement
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
|
|
@ -252,8 +344,12 @@ export default function SubscriptionPage() {
|
|||
? "bg-white text-emerald-700 hover:bg-emerald-50"
|
||||
: "bg-teal-600 hover:bg-teal-700 text-white"
|
||||
}`}
|
||||
onClick={() => handleSubscribe(p.plan)}
|
||||
onClick={() => handleSubscribe(p.plan as "basic" | "pro", p.priceId)}
|
||||
disabled={checkoutBusy || !canCheckout}
|
||||
>
|
||||
{checkoutBusy ? (
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
) : null}
|
||||
{t("subscription.subscribe")}
|
||||
</Button>
|
||||
)}
|
||||
|
|
|
|||
56
client/src/pages/SubscriptionSuccess.tsx
Normal file
56
client/src/pages/SubscriptionSuccess.tsx
Normal file
|
|
@ -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 (
|
||||
<div className="min-h-screen flex items-center justify-center p-4 bg-gradient-to-br from-emerald-50 to-cyan-50">
|
||||
<Helmet>
|
||||
<title>Paiement confirmé — QueueMed</title>
|
||||
</Helmet>
|
||||
<div className="glass-card rounded-3xl p-10 text-center max-w-md">
|
||||
<div className="w-16 h-16 rounded-2xl bg-emerald-100 border border-emerald-200 flex items-center justify-center mx-auto mb-6">
|
||||
<CheckCircle2 className="w-9 h-9 text-emerald-600" />
|
||||
</div>
|
||||
<h1 className="font-bold text-2xl mb-3">Merci pour votre abonnement !</h1>
|
||||
<p className="text-slate-600 text-sm mb-2">
|
||||
Votre paiement a été confirmé avec succès.
|
||||
</p>
|
||||
<p className="text-slate-500 text-xs mb-8 flex items-center justify-center gap-1.5">
|
||||
<Sparkles className="w-3.5 h-3.5 text-emerald-500" />
|
||||
Redirection automatique dans quelques secondes…
|
||||
</p>
|
||||
<div className="flex flex-col sm:flex-row gap-2 justify-center">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => navigate("/dashboard/subscription")}
|
||||
>
|
||||
Voir mon abonnement
|
||||
</Button>
|
||||
<Button
|
||||
variant="gradient"
|
||||
onClick={() => navigate("/dashboard")}
|
||||
>
|
||||
Aller au tableau de bord
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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 ─────────────────────────────────────────────────────────
|
||||
|
|
|
|||
133
server/services/planLimits.ts
Normal file
133
server/services/planLimits.ts
Normal file
|
|
@ -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<number> {
|
||||
const db = await getDb();
|
||||
const rows = await db
|
||||
.select({ count: sql<number>`COUNT(*)` })
|
||||
.from(clinics)
|
||||
.where(eq(clinics.userId, userId));
|
||||
return Number(rows[0]?.count ?? 0);
|
||||
}
|
||||
|
||||
async function countQueueEntriesTodayForClinic(clinicId: number): Promise<number> {
|
||||
const db = await getDb();
|
||||
const startOfDay = new Date();
|
||||
startOfDay.setHours(0, 0, 0, 0);
|
||||
const rows = await db
|
||||
.select({ count: sql<number>`COUNT(*)` })
|
||||
.from(queueEntries)
|
||||
.where(
|
||||
and(eq(queueEntries.clinicId, clinicId), gte(queueEntries.joinedAt, startOfDay))
|
||||
);
|
||||
return Number(rows[0]?.count ?? 0);
|
||||
}
|
||||
|
||||
const FEATURE_MESSAGES: Record<PlanFeature, string> = {
|
||||
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<PlanLimitCheck> {
|
||||
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) };
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue