feat: Phase 4 — real Stripe integration, feature gating, subscription flow

This commit is contained in:
Hermes 2026-04-25 17:39:08 +00:00
parent f93690610b
commit dc5cf250be
9 changed files with 572 additions and 29 deletions

View file

@ -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.

View file

@ -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">

View file

@ -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>

View 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 é 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>
);
}

View file

@ -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>
)}

View 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 é 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>
);
}

View file

@ -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());

View file

@ -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 ─────────────────────────────────────────────────────────

View 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) };
}