From c631b19dd97cc1fd8bb1e842cd4ab6020ec267da Mon Sep 17 00:00:00 2001
From: Bill Yang <45103519+goldflag@users.noreply.github.com>
Date: Tue, 15 Apr 2025 23:31:41 -0700
Subject: [PATCH] custom stripe integration (#88)
* custom stripe integration
* Refactor subscription management UI and remove unused components
- Removed ChangePlanDialog, CurrentPlanCard, and ErrorDialog components to streamline subscription management.
- Updated SubscriptionPage to define PlanTemplate locally and improve plan details handling.
- Adjusted button labels for clarity and removed unnecessary HelpSection for a cleaner interface.
- Enhanced the overall structure and readability of the SubscriptionPage component.
* Refactor SubscriptionPage to streamline plan display and enhance user experience
- Removed unused components and consolidated plan handling into FreePlan and ProPlan components for better organization.
- Simplified the SubscriptionPage structure by integrating the useStripeSubscription hook and eliminating redundant state management.
- Improved UI clarity by directly rendering plan components based on subscription status, enhancing overall user interaction.
* Refactor subscription plans and UI components for improved clarity and functionality
- Updated FreePlan and ProPlan components to enhance the display of subscription details and usage information.
- Renamed pricing tiers from "basic" to "pro" in both client and server code to reflect the new subscription structure.
- Simplified the layout of SubscriptionPage and adjusted button styles for better user experience.
- Enhanced plan details and descriptions to provide clearer information on subscription benefits and features.
* Enhance Header and UsageBanners components for improved user experience
- Updated Header component to display UsageBanners with padding for better layout.
- Simplified button styles in UsageBanners for consistency and clarity.
- Adjusted text labels in UsageBanners to better reflect user actions and subscription options.
- Removed unnecessary console log from Weekdays component to clean up code.
* Update subscription plans and event limits to reflect new pricing structure
- Changed the event limit for the free plan from 20,000 to 10,000 across client and server code.
- Renamed the "basic" plan to "pro" in the subscription management UI and related components for consistency.
- Adjusted plan details and features to align with the new subscription offerings, enhancing clarity for users.
- Removed deprecated code related to subscription handling to streamline the implementation.
---
.../app/[site]/components/Header/Header.tsx | 3 +-
.../[site]/components/Header/UsageBanners.tsx | 9 +-
.../main/components/sections/Weekdays.tsx | 1 -
.../components/ChangePlanDialog.tsx | 240 ---------
.../components/CurrentPlanCard.tsx | 179 -------
.../subscription/components/ErrorDialog.tsx | 68 ---
.../subscription/components/FreePlan.tsx | 73 +++
.../components/PlanFeaturesCard.tsx | 2 +-
.../subscription/components/ProPlan.tsx | 164 +++++++
client/src/app/settings/subscription/page.tsx | 460 +-----------------
.../settings/subscription/utils/planUtils.tsx | 19 +-
.../utils/useStripeSubscription.ts | 46 ++
client/src/app/subscribe/page.tsx | 432 ++++++++--------
client/src/components/ui/alert.tsx | 2 +-
client/src/lib/auth.ts | 10 +-
client/src/lib/stripe.ts | 28 +-
server/src/api/sites/getSites.ts | 114 +----
.../src/api/stripe/createCheckoutSession.ts | 95 ++++
server/src/api/stripe/createPortalSession.ts | 61 +++
server/src/api/stripe/getSubscription.ts | 115 +++++
server/src/api/stripe/webhook.ts | 129 +++++
server/src/api/user/getUserSubscription.ts | 84 ----
server/src/cron/monthly-usage-checker.ts | 99 ++--
server/src/db/postgres/schema.ts | 17 -
server/src/index.ts | 27 +-
server/src/lib/auth.ts | 10 -
server/src/lib/const.ts | 41 +-
server/src/lib/stripe.ts | 17 +
28 files changed, 1068 insertions(+), 1477 deletions(-)
delete mode 100644 client/src/app/settings/subscription/components/ChangePlanDialog.tsx
delete mode 100644 client/src/app/settings/subscription/components/CurrentPlanCard.tsx
delete mode 100644 client/src/app/settings/subscription/components/ErrorDialog.tsx
create mode 100644 client/src/app/settings/subscription/components/FreePlan.tsx
create mode 100644 client/src/app/settings/subscription/components/ProPlan.tsx
create mode 100644 client/src/app/settings/subscription/utils/useStripeSubscription.ts
create mode 100644 server/src/api/stripe/createCheckoutSession.ts
create mode 100644 server/src/api/stripe/createPortalSession.ts
create mode 100644 server/src/api/stripe/getSubscription.ts
create mode 100644 server/src/api/stripe/webhook.ts
delete mode 100644 server/src/api/user/getUserSubscription.ts
create mode 100644 server/src/lib/stripe.ts
diff --git a/client/src/app/[site]/components/Header/Header.tsx b/client/src/app/[site]/components/Header/Header.tsx
index 72fea71..59c751b 100644
--- a/client/src/app/[site]/components/Header/Header.tsx
+++ b/client/src/app/[site]/components/Header/Header.tsx
@@ -6,6 +6,5 @@ import { UsageBanners } from "./UsageBanners";
export function Header() {
const { user } = userStore();
- return null;
- // return
{user && }
;
+ return {user && }
;
}
diff --git a/client/src/app/[site]/components/Header/UsageBanners.tsx b/client/src/app/[site]/components/Header/UsageBanners.tsx
index 4c4ef1b..874bb8f 100644
--- a/client/src/app/[site]/components/Header/UsageBanners.tsx
+++ b/client/src/app/[site]/components/Header/UsageBanners.tsx
@@ -57,11 +57,7 @@ export function UsageBanners() {
Upgrade your plan to continue collecting analytics.
-
+
Upgrade Plan
@@ -101,12 +97,11 @@ export function UsageBanners() {
Consider upgrading your plan to avoid interruptions.
- View Plans
+ Upgrade Plan
diff --git a/client/src/app/[site]/main/components/sections/Weekdays.tsx b/client/src/app/[site]/main/components/sections/Weekdays.tsx
index ab4c216..1b6638f 100644
--- a/client/src/app/[site]/main/components/sections/Weekdays.tsx
+++ b/client/src/app/[site]/main/components/sections/Weekdays.tsx
@@ -55,7 +55,6 @@ export function Weekdays() {
// Parse the timestamp
const date = DateTime.fromSQL(item.time);
- console.log(date);
if (!date.isValid) return;
const dayOfWeek = date.weekday % 7; // 0 = Monday, 6 = Sunday in Luxon
diff --git a/client/src/app/settings/subscription/components/ChangePlanDialog.tsx b/client/src/app/settings/subscription/components/ChangePlanDialog.tsx
deleted file mode 100644
index a6bc62b..0000000
--- a/client/src/app/settings/subscription/components/ChangePlanDialog.tsx
+++ /dev/null
@@ -1,240 +0,0 @@
-import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
-import { Button } from "@/components/ui/button";
-import { Card, CardContent } from "@/components/ui/card";
-import {
- Dialog,
- DialogContent,
- DialogDescription,
- DialogFooter,
- DialogHeader,
- DialogTitle,
-} from "@/components/ui/dialog";
-import { AlertCircle, Shield } from "lucide-react";
-import { useEffect, useState } from "react";
-import { Subscription } from "@/api/admin/subscription";
-import { Badge } from "@/components/ui/badge";
-import { cn } from "@/lib/utils";
-
-interface ChangePlanDialogProps {
- showUpgradeDialog: boolean;
- setShowUpgradeDialog: (show: boolean) => void;
- actionError: string | null;
- upgradePlans: any[];
- activeSubscription: Subscription | null | undefined;
- isProcessing: boolean;
- handleUpgradeSubscription: (planId: string) => Promise;
- router: {
- push: (url: string) => void;
- };
-}
-
-export function ChangePlanDialog({
- showUpgradeDialog,
- setShowUpgradeDialog,
- actionError,
- upgradePlans,
- activeSubscription,
- isProcessing,
- handleUpgradeSubscription,
- router,
-}: ChangePlanDialogProps) {
- // State to track if we're resuming a subscription
- const [resumingPlan, setResumingPlan] = useState(null);
- // State to track billing interval preference
- const [isAnnual, setIsAnnual] = useState(false);
-
- // When dialog opens and subscription is canceled, highlight the current plan
- useEffect(() => {
- if (
- showUpgradeDialog &&
- activeSubscription?.cancelAtPeriodEnd &&
- activeSubscription?.plan
- ) {
- setResumingPlan(activeSubscription.plan);
- // Initialize the annual toggle based on the current subscription
- setIsAnnual(activeSubscription.plan.includes("-annual"));
- } else if (showUpgradeDialog && activeSubscription?.plan) {
- // Initialize the annual toggle based on the current subscription
- setIsAnnual(activeSubscription.plan.includes("-annual"));
- } else {
- setResumingPlan(null);
- }
- }, [showUpgradeDialog, activeSubscription]);
-
- // Filter plans based on the selected billing interval
- const filteredPlans = upgradePlans.filter(
- (plan) =>
- plan.name.startsWith("basic") &&
- (isAnnual
- ? plan.name.includes("-annual")
- : !plan.name.includes("-annual"))
- );
-
- return (
- {
- setShowUpgradeDialog(open);
- if (!open) setResumingPlan(null);
- }}
- >
-
-
-
- {activeSubscription?.cancelAtPeriodEnd
- ? "Resume Subscription"
- : "Change Your Plan"}
-
-
- {activeSubscription?.cancelAtPeriodEnd
- ? "Select a plan to resume your subscription. Your current plan is highlighted."
- : "Select a plan to switch to"}
-
-
-
- {actionError && (
-
-
- Error
- {actionError}
-
- )}
-
- {resumingPlan && (
-
-
- Resuming Subscription
-
- Your current plan is highlighted. Click "Select" to resume this
- plan or choose a different one.
-
-
- )}
-
- {/* Billing toggle buttons */}
-
-
-
setIsAnnual(false)}
- className={cn(
- "px-6 py-2 rounded-full text-sm font-medium transition-all",
- !isAnnual
- ? "bg-white dark:bg-neutral-700 shadow-sm text-black dark:text-white"
- : "text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-neutral-200"
- )}
- >
- Monthly
-
-
- setIsAnnual(true)}
- className={cn(
- "px-6 py-2 rounded-full text-sm font-medium transition-all",
- isAnnual
- ? "bg-white dark:bg-neutral-700 shadow-sm text-black dark:text-white"
- : "text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-neutral-200"
- )}
- >
- Annual
-
-
- 2 months free
-
-
-
-
-
-
- {/* Pro Plans */}
-
-
-
- Pro Plans
-
-
- {filteredPlans.map((plan) => (
-
-
-
-
-
- {plan.limits.events.toLocaleString()} events
- {isAnnual && (
-
- Save 17%
-
- )}
-
-
- ${plan.price} / {plan.interval}
-
-
-
{
- if (
- activeSubscription?.plan !== plan.name ||
- activeSubscription?.cancelAtPeriodEnd
- ) {
- handleUpgradeSubscription(plan.name);
- }
- }}
- disabled={
- (activeSubscription?.plan === plan.name &&
- !activeSubscription?.cancelAtPeriodEnd) ||
- isProcessing
- }
- >
- {activeSubscription?.plan === plan.name &&
- !activeSubscription?.cancelAtPeriodEnd
- ? "Current"
- : resumingPlan === plan.name &&
- resumingPlan === activeSubscription?.plan
- ? "Resume"
- : isProcessing
- ? "Processing..."
- : "Select"}
-
-
-
-
- ))}
-
-
-
-
-
- setShowUpgradeDialog(false)}>
- Cancel
-
- {
- router.push("/subscribe");
- setShowUpgradeDialog(false);
- }}
- >
- View All Plans
-
-
-
-
- );
-}
diff --git a/client/src/app/settings/subscription/components/CurrentPlanCard.tsx b/client/src/app/settings/subscription/components/CurrentPlanCard.tsx
deleted file mode 100644
index e6a2385..0000000
--- a/client/src/app/settings/subscription/components/CurrentPlanCard.tsx
+++ /dev/null
@@ -1,179 +0,0 @@
-import { Badge } from "@/components/ui/badge";
-import { Button } from "@/components/ui/button";
-import {
- Card,
- CardContent,
- CardDescription,
- CardFooter,
- CardHeader,
- CardTitle,
-} from "@/components/ui/card";
-import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
-import { Progress } from "@/components/ui/progress";
-import { Separator } from "@/components/ui/separator";
-import { Subscription } from "@/api/admin/subscription";
-import { PlanTemplate, formatDate } from "../utils/planUtils";
-import { AlertCircle, ArrowRight, X } from "lucide-react";
-
-interface CurrentPlanCardProps {
- activeSubscription: Subscription;
- currentPlan: PlanTemplate | null;
- currentUsage: number;
- eventLimit: number;
- usagePercentage: number;
- isProcessing: boolean;
- handleCancelSubscription: () => Promise;
- handleResumeSubscription: () => Promise;
- handleShowUpgradeOptions: () => void;
- upgradePlans: any[];
-}
-
-export function CurrentPlanCard({
- activeSubscription,
- currentPlan,
- currentUsage,
- eventLimit,
- usagePercentage,
- isProcessing,
- handleCancelSubscription,
- handleResumeSubscription,
- handleShowUpgradeOptions,
- upgradePlans,
-}: CurrentPlanCardProps) {
- return (
-
-
-
-
- Current Plan
- Your current subscription details
-
-
- {activeSubscription.cancelAtPeriodEnd
- ? "Cancels Soon"
- : activeSubscription?.status === "active"
- ? "Active"
- : activeSubscription?.status === "trialing"
- ? "Trial"
- : activeSubscription?.status === "canceled"
- ? "Canceled"
- : activeSubscription?.status}
-
-
-
-
-
-
-
-
Plan
-
{currentPlan?.name}
-
- {currentPlan?.price}/{currentPlan?.interval}
-
-
-
-
- {activeSubscription.cancelAtPeriodEnd ||
- activeSubscription.status === "canceled"
- ? "Ends On"
- : "Renewal Date"}
-
-
- {formatDate(activeSubscription?.periodEnd)}
-
- {activeSubscription?.cancelAt &&
- !activeSubscription.cancelAtPeriodEnd && (
-
- Cancels on {formatDate(activeSubscription?.cancelAt)}
-
- )}
-
-
-
- {activeSubscription?.trialEnd &&
- new Date(
- activeSubscription.trialEnd instanceof Date
- ? activeSubscription.trialEnd
- : String(activeSubscription.trialEnd)
- ) > new Date() && (
-
-
- Trial Period
-
- Your trial ends on {formatDate(activeSubscription?.trialEnd)}.
- You'll be charged afterward unless you cancel.
-
-
- )}
-
-
-
- {/* Usage section */}
-
-
Usage
-
-
-
- Events
-
- {currentUsage.toLocaleString()} /{" "}
- {eventLimit.toLocaleString()}
-
-
-
-
-
-
-
-
-
- {activeSubscription.cancelAtPeriodEnd ? (
-
- {isProcessing ? "Processing..." : <>Resume Subscription>}
-
- ) : (
-
- {isProcessing ? (
- "Processing..."
- ) : (
- <>
- Cancel Subscription
- >
- )}
-
- )}
-
- {/* Only show change plan button if there are other plans available */}
- {upgradePlans.length > 0 && (
-
- {isProcessing ? (
- "Processing..."
- ) : (
- <>
- Change Plan
- >
- )}
-
- )}
-
-
- );
-}
diff --git a/client/src/app/settings/subscription/components/ErrorDialog.tsx b/client/src/app/settings/subscription/components/ErrorDialog.tsx
deleted file mode 100644
index 5f853b3..0000000
--- a/client/src/app/settings/subscription/components/ErrorDialog.tsx
+++ /dev/null
@@ -1,68 +0,0 @@
-import { Button } from "@/components/ui/button";
-import {
- Dialog,
- DialogContent,
- DialogDescription,
- DialogFooter,
- DialogHeader,
- DialogTitle,
-} from "@/components/ui/dialog";
-
-interface ErrorDialogProps {
- showConfigError: boolean;
- setShowConfigError: (show: boolean) => void;
- errorType: "cancel" | "resume";
- router: {
- push: (url: string) => void;
- };
-}
-
-export function ErrorDialog({
- showConfigError,
- setShowConfigError,
- errorType,
- router,
-}: ErrorDialogProps) {
- return (
-
-
-
-
- {errorType === "cancel"
- ? "Subscription Cancellation Unavailable"
- : "Stripe Checkout Error"}
-
-
- {errorType === "cancel"
- ? "Our subscription management system is currently being configured. You cannot cancel your subscription at this time."
- : "We encountered an issue while trying to redirect you to the Stripe checkout page. Please try again or view all plans to select a new subscription."}
-
-
-
- setShowConfigError(false)}>Close
- {errorType === "resume" ? (
- {
- router.push("/subscribe");
- setShowConfigError(false);
- }}
- >
- View Plans
-
- ) : (
- {
- router.push("/contact");
- setShowConfigError(false);
- }}
- >
- Contact Support
-
- )}
-
-
-
- );
-}
diff --git a/client/src/app/settings/subscription/components/FreePlan.tsx b/client/src/app/settings/subscription/components/FreePlan.tsx
new file mode 100644
index 0000000..7deca23
--- /dev/null
+++ b/client/src/app/settings/subscription/components/FreePlan.tsx
@@ -0,0 +1,73 @@
+import { ArrowRight } from "lucide-react";
+import { Button } from "../../../../components/ui/button";
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardFooter,
+ CardHeader,
+ CardTitle,
+} from "../../../../components/ui/card";
+import { Progress } from "../../../../components/ui/progress";
+import { DEFAULT_EVENT_LIMIT } from "../utils/constants";
+import { getPlanDetails } from "../utils/planUtils";
+import { useStripeSubscription } from "../utils/useStripeSubscription";
+import { PlanFeaturesCard } from "./PlanFeaturesCard";
+import { useRouter } from "next/navigation";
+
+export function FreePlan() {
+ const {
+ data: activeSubscription,
+ isLoading,
+ error: subscriptionError,
+ refetch,
+ } = useStripeSubscription();
+
+ const currentUsage = activeSubscription?.monthlyEventCount || 0;
+
+ const router = useRouter();
+
+ return (
+
+
+
+ Free Plan
+
+ You are currently on the Free Plan. Upgrade to unlock more events.
+
+
+
+
+
+
Usage
+
+
+
+ Events
+
+ {currentUsage.toLocaleString()} /{" "}
+ {DEFAULT_EVENT_LIMIT.toLocaleString()}
+
+
+
+
+
+
+
+
+
+ router.push("/subscribe")} variant={"success"}>
+ Upgrade Plan
+
+
+
+
+
+
+ );
+}
diff --git a/client/src/app/settings/subscription/components/PlanFeaturesCard.tsx b/client/src/app/settings/subscription/components/PlanFeaturesCard.tsx
index 99e2e5f..dd4786d 100644
--- a/client/src/app/settings/subscription/components/PlanFeaturesCard.tsx
+++ b/client/src/app/settings/subscription/components/PlanFeaturesCard.tsx
@@ -37,7 +37,7 @@ export function PlanFeaturesCard({ currentPlan }: PlanFeaturesCardProps) {
- {currentPlan?.features.map((feature, i) => (
+ {currentPlan?.features?.map((feature, i) => (
{feature}
diff --git a/client/src/app/settings/subscription/components/ProPlan.tsx b/client/src/app/settings/subscription/components/ProPlan.tsx
new file mode 100644
index 0000000..aee86d6
--- /dev/null
+++ b/client/src/app/settings/subscription/components/ProPlan.tsx
@@ -0,0 +1,164 @@
+import { useState } from "react";
+import { toast } from "sonner";
+import { Button } from "../../../../components/ui/button";
+import { Card, CardContent } from "../../../../components/ui/card";
+import { Progress } from "../../../../components/ui/progress";
+import { DEFAULT_EVENT_LIMIT } from "../utils/constants";
+import { formatDate, getPlanDetails, PlanTemplate } from "../utils/planUtils";
+import { useStripeSubscription } from "../utils/useStripeSubscription";
+import { PlanFeaturesCard } from "./PlanFeaturesCard";
+import { Alert } from "../../../../components/ui/alert";
+
+export function ProPlan() {
+ const {
+ data: activeSubscription,
+ isLoading,
+ error: subscriptionError,
+ refetch,
+ } = useStripeSubscription();
+
+ const [isProcessing, setIsProcessing] = useState(false);
+ const [actionError, setActionError] = useState(null);
+
+ const eventLimit = activeSubscription?.eventLimit || DEFAULT_EVENT_LIMIT;
+ const currentUsage = activeSubscription?.monthlyEventCount || 0;
+ const usagePercentage =
+ eventLimit > 0 ? Math.min((currentUsage / eventLimit) * 100, 100) : 0;
+ const isAnnualPlan = activeSubscription?.interval === "year";
+
+ const currentPlanDetails: PlanTemplate | null = activeSubscription
+ ? getPlanDetails(activeSubscription.planName)
+ : null;
+
+ const handleManageSubscription = async () => {
+ setActionError(null);
+ setIsProcessing(true);
+ try {
+ const backendUrl = process.env.NEXT_PUBLIC_BACKEND_URL || "";
+ const response = await fetch(
+ `${backendUrl}/api/stripe/create-portal-session`,
+ {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ },
+ credentials: "include",
+ body: JSON.stringify({
+ returnUrl: window.location.href,
+ }),
+ }
+ );
+
+ const data = await response.json();
+
+ if (!response.ok) {
+ throw new Error(data.error || "Failed to create portal session.");
+ }
+
+ if (data.portalUrl) {
+ window.location.href = data.portalUrl;
+ } else {
+ throw new Error("Portal URL not received.");
+ }
+ } catch (err: any) {
+ console.error("Portal Session Error:", err);
+ setActionError(err.message || "Could not open billing portal.");
+ toast.error(`Error: ${err.message || "Could not open billing portal."}`);
+ } finally {
+ setIsProcessing(false);
+ }
+ };
+
+ const getFormattedPrice = () => {
+ if (!currentPlanDetails) return "$0/month";
+ return `${currentPlanDetails.price}/${
+ currentPlanDetails.interval === "year" ? "year" : "month"
+ }`;
+ };
+
+ const formatRenewalDate = () => {
+ if (!activeSubscription?.currentPeriodEnd) return "N/A";
+ const formattedDate = formatDate(activeSubscription.currentPeriodEnd);
+
+ if (activeSubscription.cancelAtPeriodEnd) {
+ return `Ends on ${formattedDate}`;
+ }
+ if (activeSubscription.status === "active") {
+ return isAnnualPlan
+ ? `Renews annually on ${formattedDate}`
+ : `Renews monthly on ${formattedDate}`;
+ }
+ return `Status: ${activeSubscription.status}, ends/renews ${formattedDate}`;
+ };
+
+ if (!activeSubscription) {
+ return null;
+ }
+
+ return (
+
+ {actionError &&
{actionError} }
+
+
+
+
+
+
+ {currentPlanDetails?.name || activeSubscription.planName}
+
+
{getFormattedPrice()}
+ {isAnnualPlan && (
+
+
You save by paying annually (2 months free)
+
+ )}
+
+ {formatRenewalDate()}
+
+
+
+ {isProcessing ? "Processing..." : "Change Plan"}
+
+
+
+
+
Usage
+
+
+
+ Events
+
+ {currentUsage.toLocaleString()} /{" "}
+ {eventLimit.toLocaleString()}
+
+
+
+
+
+
+
+ {isAnnualPlan && (
+
+
+ Annual Billing: You're on annual billing
+ which saves you money compared to monthly billing. Your
+ subscription will renew once per year on{" "}
+ {formatDate(activeSubscription.currentPeriodEnd)}.
+
+
+ )}
+
+
+
+
+ {/* Conditionally render PlanFeaturesCard only when details are available */}
+ {!isLoading && currentPlanDetails && (
+
+ )}
+
+ );
+}
diff --git a/client/src/app/settings/subscription/page.tsx b/client/src/app/settings/subscription/page.tsx
index 2f1c8f0..c0b3dc6 100644
--- a/client/src/app/settings/subscription/page.tsx
+++ b/client/src/app/settings/subscription/page.tsx
@@ -1,253 +1,28 @@
"use client";
-import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
-import { Button } from "@/components/ui/button";
-import {
- Card,
- CardContent,
- CardDescription,
- CardFooter,
- CardHeader,
- CardTitle,
-} from "@/components/ui/card";
+import { Card, CardContent } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
-import { STRIPE_PRICES } from "@/lib/stripe";
-import { AlertCircle, ArrowRight } from "lucide-react";
-import { useRouter } from "next/navigation";
-import { useState } from "react";
-import { useSubscriptionWithUsage } from "../../../api/admin/subscription";
-import { authClient } from "../../../lib/auth";
-import { ChangePlanDialog } from "./components/ChangePlanDialog";
-import { CurrentPlanCard } from "./components/CurrentPlanCard";
-import { ErrorDialog } from "./components/ErrorDialog";
-import { HelpSection } from "./components/HelpSection";
-import { PlanFeaturesCard } from "./components/PlanFeaturesCard";
-import { DEFAULT_EVENT_LIMIT } from "./utils/constants";
-import { getPlanDetails, formatDate } from "./utils/planUtils";
-import { Progress } from "@/components/ui/progress";
-import { Badge } from "@/components/ui/badge";
+import { FreePlan } from "./components/FreePlan";
+import { ProPlan } from "./components/ProPlan";
+import { useStripeSubscription } from "./utils/useStripeSubscription";
export default function SubscriptionPage() {
- const router = useRouter();
-
const {
data: activeSubscription,
isLoading,
error: subscriptionError,
refetch,
- } = useSubscriptionWithUsage();
-
- console.info("activeSubscription", activeSubscription);
-
- // State variables
- const [errorType, setErrorType] = useState<"cancel" | "resume">("cancel");
- const [showConfigError, setShowConfigError] = useState(false);
- const [showUpgradeDialog, setShowUpgradeDialog] = useState(false);
- const [isProcessing, setIsProcessing] = useState(false);
- const [actionError, setActionError] = useState(null);
-
- // Current usage - in a real app, you would fetch this from your API
- const currentUsage = activeSubscription?.monthlyEventCount || 0;
-
- const handleCancelSubscription = async () => {
- try {
- setIsProcessing(true);
- setErrorType("cancel");
- setActionError(null);
-
- // Don't pass referenceId if it's the same as the user ID
- // This is because Better Auth defaults to the user ID when no referenceId is provided
- const { error } = await authClient.subscription.cancel({
- returnUrl: globalThis.location.href,
- });
-
- if (error) {
- // Check for specific error about Stripe portal configuration
- if (
- error.message?.includes("No configuration provided") ||
- error.message?.includes("default configuration has not been created")
- ) {
- // Show the error dialog instead of an alert
- setShowConfigError(true);
-
- // Log detailed instructions for developers/admins
- console.error(
- "Stripe Customer Portal not configured. Admin needs to set up the Customer Portal at https://dashboard.stripe.com/test/settings/billing/portal"
- );
- } else {
- setActionError(
- error.message ||
- "An error occurred while canceling the subscription"
- );
- }
- }
- // The user will be redirected to Stripe's billing portal if successful
- } catch (err: any) {
- console.error("Failed to cancel subscription:", err);
- setActionError(err.message || "Failed to cancel subscription");
- } finally {
- setIsProcessing(false);
- }
- };
-
- const handleUpgradeSubscription = async (planId: string) => {
- try {
- setIsProcessing(true);
- setActionError(null);
-
- // Don't pass referenceId if it's the same as the user ID
- // Better Auth defaults to the user ID when no referenceId is provided
- const { error } = await authClient.subscription.upgrade({
- plan: planId,
- cancelUrl: globalThis.location.origin + "/settings/subscription",
- successUrl: globalThis.location.origin + "/auth/subscription/success",
- });
-
- if (error) {
- setActionError(
- error.message || "An error occurred while changing the plan"
- );
- }
- // The user will be redirected to Stripe checkout if successful
- } catch (err: any) {
- console.error("Failed to change plan:", err);
- setActionError(err.message || "Failed to change plan");
- } finally {
- setIsProcessing(false);
- }
- };
-
- const handleResumeSubscription = async () => {
- try {
- setIsProcessing(true);
- setErrorType("resume");
- setActionError(null);
-
- // Check if we have the plan information
- if (!activeSubscription?.plan) {
- setActionError(
- "Cannot resume subscription: plan information is missing"
- );
- setShowConfigError(true);
- return;
- }
-
- // Directly use the upgrade method to take the user to Stripe checkout
- // with the same plan they currently have
- const { error } = await authClient.subscription.upgrade({
- plan: activeSubscription.plan,
- successUrl: globalThis.location.origin + "/auth/subscription/success",
- cancelUrl: globalThis.location.origin + "/settings/subscription",
- });
-
- if (error) {
- setActionError(
- error.message || "An error occurred while resuming the subscription"
- );
- setShowConfigError(true);
- }
- // The user will be redirected to Stripe checkout if successful
- } catch (err: any) {
- console.error("Failed to resume subscription:", err);
- setActionError(err.message || "Failed to resume subscription");
- setShowConfigError(true);
- } finally {
- setIsProcessing(false);
- }
- };
-
- const handleShowUpgradeOptions = () => {
- setShowUpgradeDialog(true);
- setActionError(null);
- };
-
- // Get information about current plan if there's an active subscription
- const currentPlan = activeSubscription
- ? getPlanDetails(activeSubscription.plan)
- : null;
-
- // Determine if the current plan is annual
- const isAnnualPlan = activeSubscription?.plan?.includes("-annual") || false;
-
- // Find the next tier plans for upgrade options
- const getCurrentTierPrices = () => {
- if (!activeSubscription?.plan) return [];
-
- // Return all available plans for switching, regardless of interval
- // The ChangePlanDialog will handle filtering by interval
- return STRIPE_PRICES.sort((a, b) => {
- // First sort by plan type (basic only now)
-
- // Then sort by interval (month first, then year)
- if (a.interval === "month" && b.interval === "year") return -1;
- if (a.interval === "year" && b.interval === "month") return 1;
-
- // Then sort by event limit
- return a.limits.events - b.limits.events;
- });
- };
-
- const upgradePlans = getCurrentTierPrices();
-
- const errorMessage = subscriptionError?.message || actionError || null;
-
- // Get event limit from the subscription plan
- const getEventLimit = () => {
- if (!activeSubscription?.plan) return DEFAULT_EVENT_LIMIT;
-
- const plan = STRIPE_PRICES.find((p) => p.name === activeSubscription.plan);
- return plan?.limits.events || DEFAULT_EVENT_LIMIT;
- };
-
- const eventLimit = getEventLimit();
- const usagePercentage = (currentUsage / eventLimit) * 100;
-
- // Format the price with the correct interval
- const formatPriceWithInterval = (price: number, interval: string) => {
- return `$${price}/${interval === "year" ? "year" : "month"}`;
- };
-
- // Get formatted price for display
- const getFormattedPrice = () => {
- if (!activeSubscription?.plan) return "$0/month";
-
- const plan = STRIPE_PRICES.find((p) => p.name === activeSubscription.plan);
- if (!plan) return "$0/month";
-
- return formatPriceWithInterval(plan.price, plan.interval);
- };
-
- // Format the renewal date with appropriate text
- const formatRenewalDate = () => {
- if (!activeSubscription?.periodEnd) return "N/A";
-
- const formattedDate = formatDate(activeSubscription.periodEnd);
-
- if (activeSubscription.status === "canceled") {
- return `Expires on ${formattedDate}`;
- }
-
- if (activeSubscription.cancelAtPeriodEnd) {
- return `Ends on ${formattedDate}`;
- }
-
- return isAnnualPlan
- ? `Renews annually on ${formattedDate}`
- : `Renews monthly on ${formattedDate}`;
- };
+ } = useStripeSubscription();
return (
-
-
+
+
Subscription
Manage your subscription and billing information
-
router.push("/subscribe")}>
- View Plans
-
{isLoading ? (
@@ -260,226 +35,11 @@ export default function SubscriptionPage() {
- ) : errorMessage ? (
-
-
- Error
- {errorMessage}
-
- ) : !activeSubscription?.plan ? (
-
-
-
- Free Plan
-
- You are currently on the Free Plan. Upgrade to unlock premium
- features.
-
-
-
-
-
-
-
Plan
-
Free
-
- $0/month
-
-
-
-
Renewal Date
-
Never expires
-
-
-
-
-
Usage
-
-
-
- Events
-
- {currentUsage.toLocaleString()} /{" "}
- {DEFAULT_EVENT_LIMIT.toLocaleString()}
-
-
-
-
-
-
-
-
-
- router.push("/subscribe")}>
- Upgrade Plan
-
-
-
-
-
-
-
-
+ ) : !activeSubscription ? (
+
) : (
-
- {/* Current Plan */}
-
-
-
-
-
- {currentPlan?.name || "Current Plan"}
- {isAnnualPlan && (
-
- Annual
-
- )}
- {activeSubscription.cancelAtPeriodEnd && (
-
- Canceling
-
- )}
-
-
- {activeSubscription.cancelAtPeriodEnd
- ? "Your subscription will be canceled at the end of the current billing period."
- : activeSubscription.status === "active"
- ? "Your subscription is active."
- : activeSubscription.status === "canceled"
- ? "Your subscription has been canceled but is still active until the end of the billing period."
- : "Your subscription is inactive."}
-
-
-
- {activeSubscription.status === "active" && (
-
- Change Plan
-
- )}
-
-
-
-
-
-
-
-
Plan
-
{currentPlan?.name}
-
- {getFormattedPrice()}
-
- {isAnnualPlan && (
-
-
You save by paying annually (2 months free)
-
- )}
-
-
-
Renewal Date
-
{formatRenewalDate()}
-
- {activeSubscription.cancelAtPeriodEnd
- ? "Your subscription will not renew after this date"
- : isAnnualPlan
- ? "Your plan renews once per year"
- : "Your plan renews monthly"}
-
-
-
-
-
-
Usage
-
-
-
- Events
-
- {currentUsage.toLocaleString()} /{" "}
- {eventLimit.toLocaleString()}
-
-
-
-
-
-
-
- {/* Billing Cycle Explanation */}
- {isAnnualPlan && (
-
-
- Annual Billing: You're on annual billing
- which saves you money compared to monthly billing. Your
- subscription will renew once per year on{" "}
- {formatDate(activeSubscription.periodEnd)}.
-
-
- )}
-
-
-
- {activeSubscription.status === "active" ? (
- activeSubscription.cancelAtPeriodEnd ? (
-
- {isProcessing ? "Processing..." : "Resume Subscription"}
-
- ) : (
-
- {isProcessing ? "Processing..." : "Cancel Subscription"}
-
- )
- ) : (
-
- {isProcessing ? "Processing..." : "Resume Subscription"}
-
- )}
-
-
-
- {/* Plan Features */}
-
-
+
)}
-
- {/* Help section */}
-
-
- {/* Error dialog */}
-
-
- {/* Change plan dialog */}
-
);
}
diff --git a/client/src/app/settings/subscription/utils/planUtils.tsx b/client/src/app/settings/subscription/utils/planUtils.tsx
index 8d44cb7..2b0c29e 100644
--- a/client/src/app/settings/subscription/utils/planUtils.tsx
+++ b/client/src/app/settings/subscription/utils/planUtils.tsx
@@ -19,7 +19,7 @@ export const getPlanDetails = (
): PlanTemplate | null => {
if (!planName) return null;
- const tier = planName.startsWith("basic") ? "basic" : "free";
+ const tier = planName.startsWith("pro") ? "pro" : "free";
const stripePlan = STRIPE_PRICES.find((p) => p.name === planName);
const planTemplates: Record
= {
@@ -29,27 +29,18 @@ export const getPlanDetails = (
price: "$0",
interval: "month",
description: "Get started with basic analytics",
- features: [
- "20,000 events per month",
- "Basic analytics",
- "7-day data retention",
- "Community support",
- ],
+ features: ["10,000 events per month", "6 month data retention"],
color:
"bg-gradient-to-br from-gray-100 to-gray-200 dark:from-gray-800 dark:to-gray-900",
icon: ,
},
- basic: {
- id: "basic",
+ pro: {
+ id: "pro",
name: "Pro",
price: "$19+",
interval: "month",
description: "Advanced analytics for growing projects",
- features: [
- "Advanced analytics features",
- "14-day data retention",
- "Priority support",
- ],
+ features: ["5 year data retention", "Priority support"],
color:
"bg-gradient-to-br from-green-50 to-emerald-100 dark:from-green-800 dark:to-emerald-800",
icon: ,
diff --git a/client/src/app/settings/subscription/utils/useStripeSubscription.ts b/client/src/app/settings/subscription/utils/useStripeSubscription.ts
new file mode 100644
index 0000000..decf646
--- /dev/null
+++ b/client/src/app/settings/subscription/utils/useStripeSubscription.ts
@@ -0,0 +1,46 @@
+import { useQuery } from "@tanstack/react-query";
+
+interface SubscriptionData {
+ id: string;
+ planName: string;
+ status: string;
+ currentPeriodEnd: string;
+ monthlyEventCount: number;
+ eventLimit: number;
+ interval: string;
+ cancelAtPeriodEnd?: boolean;
+}
+
+export function useStripeSubscription() {
+ const backendUrl = process.env.NEXT_PUBLIC_BACKEND_URL || "";
+
+ const fetchSubscription = async () => {
+ const response = await fetch(`${backendUrl}/api/stripe/subscription`, {
+ method: "GET",
+ headers: {
+ "Content-Type": "application/json",
+ },
+ credentials: "include",
+ });
+
+ if (!response.ok) {
+ if (response.status === 401) {
+ return null;
+ } else {
+ const errorData = await response.json();
+ throw new Error(errorData.error || `Error: ${response.status}`);
+ }
+ }
+
+ return response.json();
+ };
+
+ const { data, isLoading, error, refetch } = useQuery({
+ queryKey: ["stripe-subscription"],
+ queryFn: fetchSubscription,
+ staleTime: 5 * 60 * 1000, // 5 minutes
+ retry: false,
+ });
+
+ return { data, isLoading, error, refetch };
+}
diff --git a/client/src/app/subscribe/page.tsx b/client/src/app/subscribe/page.tsx
index b6992c7..c40e6df 100644
--- a/client/src/app/subscribe/page.tsx
+++ b/client/src/app/subscribe/page.tsx
@@ -1,33 +1,32 @@
"use client";
-import { useRouter } from "next/navigation";
-import { useState, useEffect } from "react";
-import { Check, Users } from "lucide-react";
-import { authClient } from "@/lib/auth";
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
import {
Card,
+ CardContent,
+ CardDescription,
+ CardFooter,
CardHeader,
CardTitle,
- CardDescription,
- CardContent,
- CardFooter,
} from "@/components/ui/card";
-import { Button } from "@/components/ui/button";
-import { STRIPE_PRICES } from "@/lib/stripe";
import { Slider } from "@/components/ui/slider";
-import { Badge } from "@/components/ui/badge";
+import { authClient } from "@/lib/auth";
+import { STRIPE_PRICES } from "@/lib/stripe";
import { cn } from "@/lib/utils";
+import { Check } from "lucide-react";
+import { useEffect, useState } from "react";
+import { toast } from "sonner";
import { StandardPage } from "../../components/StandardPage";
// Available event tiers for the slider
const EVENT_TIERS = [
- 20_000, 100_000, 250_000, 500_000, 1_000_000, 2_000_000, 5_000_000,
- 10_000_000,
+ 100_000, 250_000, 500_000, 1_000_000, 2_000_000, 5_000_000, 10_000_000,
];
// Define types for plans
interface PlanTemplate {
- id: "free" | "basic";
+ id: "free" | "pro";
name: string;
price?: string;
interval?: string;
@@ -63,23 +62,15 @@ const PLAN_TEMPLATES: PlanTemplate[] = [
price: "$0",
interval: "month",
description: "Get started with basic analytics",
- baseFeatures: [
- "Basic analytics",
- "7-day data retention",
- "Community support",
- ],
+ baseFeatures: ["6 month data retention"],
color:
"bg-gradient-to-br from-neutral-100 to-neutral-200 dark:from-neutral-800 dark:to-neutral-900",
},
{
- id: "basic",
+ id: "pro",
name: "Pro",
description: "Advanced analytics for growing projects",
- baseFeatures: [
- "Advanced analytics features",
- "14-day data retention",
- "Priority support",
- ],
+ baseFeatures: ["5 year data retention", "Priority support"],
color:
"bg-gradient-to-br from-green-50 to-emerald-100 dark:from-green-800 dark:to-emerald-800",
},
@@ -92,7 +83,7 @@ function getFormattedPrice(price: number): string {
// Find the appropriate price for a tier at current event limit
function findPriceForTier(
- tier: "basic",
+ tier: "free" | "pro",
eventLimit: number,
interval: "month" | "year"
): StripePrice | null {
@@ -154,134 +145,85 @@ function calculateSavings(monthlyPrice: number, annualPrice: number): string {
return `Save ${savingsPercent}%`;
}
-// Function to get the direct plan ID based on criteria
-function getDirectPlanID(
- tier: "basic",
- eventLimit: number,
- isAnnual: boolean
-): string {
- // Base pattern for plan names is like "basic100k" or "pro250k"
- let planPrefix = tier;
- let eventSuffix = "";
-
- // Determine event tier suffix
- if (eventLimit <= 100_000) {
- eventSuffix = "100k";
- } else if (eventLimit <= 250_000) {
- eventSuffix = "250k";
- } else if (eventLimit <= 500_000) {
- eventSuffix = "500k";
- } else if (eventLimit <= 1_000_000) {
- eventSuffix = "1m";
- } else if (eventLimit <= 2_000_000) {
- eventSuffix = "2m";
- } else if (eventLimit <= 5_000_000) {
- eventSuffix = "5m";
- } else {
- eventSuffix = "10m";
- }
-
- // Construct the plan name with annual suffix if needed
- const planName = `${planPrefix}${eventSuffix}${isAnnual ? "-annual" : ""}`;
- console.log(`Constructed plan name: ${planName} for isAnnual=${isAnnual}`);
-
- return planName;
-}
-
export default function Subscribe() {
- const [selectedTier, setSelectedTier] = useState<"free" | "basic">("free");
+ const [selectedTier, setSelectedTier] = useState<"free" | "pro">("free");
const [eventLimitIndex, setEventLimitIndex] = useState(0); // Default to 20k (index 0)
- const [selectedPrice, setSelectedPrice] = useState(null);
- const [isAnnual, setIsAnnual] = useState(false);
+ const [isAnnual, setIsAnnual] = useState(true);
+ const [isLoading, setIsLoading] = useState(false);
+ const { data: sessionData } = authClient.useSession();
- // Get the actual event limit value from the index
const eventLimit = EVENT_TIERS[eventLimitIndex];
- // Check if free plan is available based on event limit
- const isFreeAvailable = eventLimit <= 20_000;
-
- // Group plans by type and interval
- const basicMonthlyPlans = STRIPE_PRICES.filter(
- (plan) => plan.name.startsWith("basic") && !plan.name.includes("-annual")
- );
- const basicAnnualPlans = STRIPE_PRICES.filter(
- (plan) => plan.name.includes("basic") && plan.name.includes("-annual")
- );
-
- // Update the selected price when tier or event limit changes
- useEffect(() => {
- if (selectedTier === "free") {
- setSelectedPrice(null);
- return;
- }
-
- // Get the correct set of plans based on the tier and interval
- let filteredPlans;
- if (selectedTier === "basic") {
- filteredPlans = isAnnual ? basicAnnualPlans : basicMonthlyPlans;
- }
-
- const matchingPlan =
- filteredPlans?.find((plan) => plan.limits.events >= eventLimit) ||
- filteredPlans?.[filteredPlans.length - 1];
- if (matchingPlan) {
- setSelectedPrice(matchingPlan);
- }
- }, [selectedTier, eventLimit, isAnnual]);
+ // TODO: Implement proper check if user already has an active subscription
+ const isFreeAvailable = !!sessionData?.user; // Placeholder check based on login
// Handle subscription
- function handleSubscribe(planId: "free" | "basic"): void {
- setSelectedTier(planId);
-
- if (planId === "free") return;
-
- // Use the direct plan mapping approach with the new naming scheme
- const planName = getDirectPlanID("basic", eventLimit, isAnnual);
-
- console.log(
- `Direct plan mapping selected: ${planName}, interval=${
- isAnnual ? "year" : "month"
- }`
- );
-
- // Find the specific plan object in STRIPE_PRICES
- const interval = isAnnual ? "year" : "month";
-
- // The filter should match the new naming convention for annual plans
- const matchingPlans = STRIPE_PRICES.filter((p) => {
- const nameMatches = p.name === planName;
- const intervalMatches = p.interval === interval;
- return nameMatches && intervalMatches;
- });
-
- console.log(
- `Found ${matchingPlans.length} matching plans for ${planName} (${interval})`
- );
-
- if (matchingPlans.length === 0) {
- console.error(
- `No matching plan found for name=${planName}, interval=${interval}`
- );
+ async function handleSubscribe(planId: "free" | "pro"): Promise {
+ if (planId === "free") {
return;
}
- const selectedPlan = matchingPlans[0];
- console.log(`Selected plan: `, selectedPlan);
+ // Check if user is logged in directly
+ if (!sessionData?.user) {
+ toast.error("Please log in to subscribe.");
+ return;
+ }
- // Log the selected plan to verify
- console.log(
- `Subscribing to ${selectedPlan.name} (${selectedPlan.interval}) - $${selectedPlan.price} - ${selectedPlan.limits.events} events`
- );
+ if (planId === "pro") {
+ const selectedTierPrice = findPriceForTier(
+ "pro",
+ eventLimit,
+ isAnnual ? "year" : "month"
+ );
- authClient.subscription
- .upgrade({
- plan: selectedPlan.name,
- successUrl: globalThis.location.origin + "/auth/subscription/success",
- cancelUrl: globalThis.location.origin + "/subscribe",
- })
- .catch((error) => {
- console.error("Subscription error:", error);
- });
+ if (!selectedTierPrice) {
+ toast.error(
+ "Selected pricing plan not found. Please adjust the slider."
+ );
+ return;
+ }
+
+ setIsLoading(true);
+ try {
+ // Use NEXT_PUBLIC_BACKEND_URL if available, otherwise use relative path for same-origin requests
+ const backendBaseUrl = process.env.NEXT_PUBLIC_BACKEND_URL || "";
+ const baseUrl = window.location.origin;
+ const successUrl = `${baseUrl}/settings/subscription?session_id={CHECKOUT_SESSION_ID}`;
+ const cancelUrl = `${baseUrl}/subscribe`;
+
+ const response = await fetch(
+ `${backendBaseUrl}/api/stripe/create-checkout-session`,
+ {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ },
+ credentials: "include", // Send cookies
+ body: JSON.stringify({
+ priceId: selectedTierPrice.priceId,
+ successUrl: successUrl,
+ cancelUrl: cancelUrl,
+ }),
+ }
+ );
+
+ const data = await response.json();
+
+ if (!response.ok) {
+ throw new Error(data.error || "Failed to create checkout session.");
+ }
+
+ if (data.checkoutUrl) {
+ window.location.href = data.checkoutUrl; // Redirect to Stripe checkout
+ } else {
+ throw new Error("Checkout URL not received.");
+ }
+ } catch (error: any) {
+ console.error("Subscription Error:", error);
+ toast.error(`Subscription failed: ${error.message}`);
+ setIsLoading(false); // Stop loading on error
+ }
+ }
}
// Handle slider changes
@@ -289,24 +231,24 @@ export default function Subscribe() {
setEventLimitIndex(value[0]);
// If event limit is over 20k, ensure free plan is not selected
- if (EVENT_TIERS[value[0]] > 20_000 && selectedTier === "free") {
- setSelectedTier("basic");
+ if (EVENT_TIERS[value[0]] > 10_000 && selectedTier === "free") {
+ setSelectedTier("pro");
}
}
// Find the current prices for each tier based on the event limit
const interval = isAnnual ? "year" : "month";
- const basicTierPrice = findPriceForTier("basic", eventLimit, interval);
+ const basicTierPrice = findPriceForTier("pro", eventLimit, interval);
// Also get monthly prices for savings calculation
- const basicMonthly = findPriceForTier("basic", eventLimit, "month");
- const basicAnnual = findPriceForTier("basic", eventLimit, "year");
+ const basicMonthly = findPriceForTier("pro", eventLimit, "month");
+ const basicAnnual = findPriceForTier("pro", eventLimit, "year");
// Generate plan objects with current state
const plans: Plan[] = PLAN_TEMPLATES.map((template) => {
const plan = { ...template } as Plan;
- if (plan.id === "basic") {
+ if (plan.id === "pro") {
const tierPrice = basicTierPrice;
plan.price = tierPrice ? getFormattedPrice(tierPrice.price) : "$19+";
plan.interval = isAnnual ? "year" : "month";
@@ -324,7 +266,7 @@ export default function Subscribe() {
// Add event limit feature at the beginning
const eventFeature =
plan.id === "free"
- ? "20,000 events per month"
+ ? "10,000 events per month"
: `${Math.max(eventLimit, 100_000).toLocaleString()} events per month`;
plan.features = [eventFeature, ...plan.baseFeatures];
@@ -342,80 +284,9 @@ export default function Subscribe() {
Find the perfect plan to track your site's performance
-
- {/* Billing toggle buttons */}
-
-
-
setIsAnnual(false)}
- className={cn(
- "px-6 py-2 rounded-full text-sm font-medium transition-all",
- !isAnnual
- ? "bg-white dark:bg-neutral-700 shadow-sm text-black dark:text-white"
- : "text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-neutral-200"
- )}
- >
- Monthly
-
-
- setIsAnnual(true)}
- className={cn(
- "px-6 py-2 rounded-full text-sm font-medium transition-all",
- isAnnual
- ? "bg-white dark:bg-neutral-700 shadow-sm text-black dark:text-white"
- : "text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-neutral-200"
- )}
- >
- Annual
-
-
- 2 months free
-
-
-
-
-
-
-
- How many events do you need?
-
-
-
- Events per month
-
-
- {eventLimit.toLocaleString()}
-
-
-
-
-
-
-
- {EVENT_TIERS.map((tier, index) => (
-
- {tier.toLocaleString()}
-
- ))}
-
-
-
-
+
{plans.map((plan) => (
- {plan.name}
-
-
-
- {plan.price}
-
- {plan.interval && (
-
- /{plan.interval}
-
- )}
-
- {isAnnual && plan.id !== "free" && (
-
- 2 months free
-
- )}
+ {plan.name}
+
{plan.description}
-
+ {plan.id === "pro" && (
+ <>
+
+ {/* Billing toggle buttons */}
+
+
+
setIsAnnual(false)}
+ className={cn(
+ "px-6 py-2 rounded-lg text-sm font-medium transition-all",
+ !isAnnual
+ ? "bg-white dark:bg-neutral-700 shadow-sm text-black dark:text-white"
+ : "text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-neutral-200"
+ )}
+ >
+ Monthly
+
+
+ setIsAnnual(true)}
+ className={cn(
+ "px-6 py-2 rounded-lg text-sm font-medium transition-all",
+ isAnnual
+ ? "bg-white dark:bg-neutral-700 shadow-sm text-black dark:text-white"
+ : "text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-neutral-200"
+ )}
+ >
+ Annual
+
+
+ 2 months free
+
+
+
+
+
+ {/* Event slider */}
+
+
+
+
+ Events per month
+
+ {eventLimit.toLocaleString()}
+
+
+
+
+ {plan.price}
+
+ {plan.interval && (
+
+ /{plan.interval}
+
+ )}
+
+
+
+
+
+
+
+ {EVENT_TIERS.map((tier, index) => (
+
+ {tier >= 1_000_000
+ ? `${tier / 1_000_000}M`
+ : `${tier / 1_000}K`}
+
+ ))}
+
+
+
+ >
+ )}
+
+
{plan.features.map((feature, i) => (
@@ -466,12 +414,16 @@ export default function Subscribe() {
- {plan.id === "basic" ? (
+ {plan.id === "pro" ? (
handleSubscribe(plan.id)}
className="w-full"
+ disabled={isLoading}
+ variant="success"
>
- Subscribe to {plan.name}
+ {isLoading
+ ? "Processing..."
+ : `Subscribe to ${plan.name}`}
) : (
svg]:text-red-500 dark:bg-red-950 dark:border-red-900/50 dark:text-red-400 dark:dark:border-red-900 dark:[&>svg]:text-red-400",
+ "border-red-500/50 text-red-500 dark:border-red-500 [&>svg]:text-red-500 dark:bg-red-950/70 dark:border-red-900/50 dark:text-red-400 dark:dark:border-red-900 dark:[&>svg]:text-red-400",
},
},
defaultVariants: {
diff --git a/client/src/lib/auth.ts b/client/src/lib/auth.ts
index bd4a523..5fc00ca 100644
--- a/client/src/lib/auth.ts
+++ b/client/src/lib/auth.ts
@@ -4,18 +4,10 @@ import {
usernameClient,
} from "better-auth/client/plugins";
import { createAuthClient } from "better-auth/react";
-import { stripeClient } from "@better-auth/stripe/client";
export const authClient = createAuthClient({
baseURL: process.env.NEXT_PUBLIC_BACKEND_URL,
- plugins: [
- usernameClient(),
- adminClient(),
- organizationClient(),
- stripeClient({
- subscription: true,
- }),
- ],
+ plugins: [usernameClient(), adminClient(), organizationClient()],
fetchOptions: {
credentials: "include",
},
diff --git a/client/src/lib/stripe.ts b/client/src/lib/stripe.ts
index c5e856c..590ff07 100644
--- a/client/src/lib/stripe.ts
+++ b/client/src/lib/stripe.ts
@@ -2,7 +2,7 @@ export const STRIPE_PRICES = [
{
priceId: "price_1R1fIVDFVprnAny2yJtRRPBm",
price: 19,
- name: "basic100k",
+ name: "pro100k",
interval: "month",
limits: {
events: 100_000,
@@ -11,7 +11,7 @@ export const STRIPE_PRICES = [
{
priceId: "price_1R2l2KDFVprnAny2iZr5gFLe",
price: 190,
- name: "basic100k-annual",
+ name: "pro100k-annual",
interval: "year",
limits: {
events: 100_000,
@@ -20,7 +20,7 @@ export const STRIPE_PRICES = [
{
priceId: "price_1R1fKJDFVprnAny2mfiBjkAQ",
price: 29,
- name: "basic250k",
+ name: "pro250k",
interval: "month",
limits: {
events: 250_000,
@@ -29,14 +29,14 @@ export const STRIPE_PRICES = [
{
priceId: "price_1R2lJIDFVprnAny22zUvjg5o",
price: 290,
- name: "basic250k-annual",
+ name: "pro250k-annual",
interval: "year",
limits: {
events: 250_000,
},
},
{
- name: "basic500k",
+ name: "pro500k",
priceId: "price_1R1fQlDFVprnAny2WwNdiRgT",
price: 49,
interval: "month",
@@ -45,7 +45,7 @@ export const STRIPE_PRICES = [
},
},
{
- name: "basic500k-annual",
+ name: "pro500k-annual",
priceId: "price_1R2lKIDFVprnAny27wXUAy2D",
price: 490,
interval: "year",
@@ -54,7 +54,7 @@ export const STRIPE_PRICES = [
},
},
{
- name: "basic1m",
+ name: "pro1m",
priceId: "price_1R1fR2DFVprnAny28tPEQAwh",
price: 69,
interval: "month",
@@ -63,7 +63,7 @@ export const STRIPE_PRICES = [
},
},
{
- name: "basic1m-annual",
+ name: "pro1m-annual",
priceId: "price_1R2lKtDFVprnAny2Xl98rgu4",
price: 690,
interval: "year",
@@ -72,7 +72,7 @@ export const STRIPE_PRICES = [
},
},
{
- name: "basic2m",
+ name: "pro2m",
priceId: "price_1R1fRMDFVprnAny24AMo0Vuu",
price: 99,
interval: "month",
@@ -81,7 +81,7 @@ export const STRIPE_PRICES = [
},
},
{
- name: "basic2m-annual",
+ name: "pro2m-annual",
priceId: "price_1RE1bQDFVprnAny2ELKQS79d",
price: 990,
interval: "year",
@@ -90,7 +90,7 @@ export const STRIPE_PRICES = [
},
},
{
- name: "basic5m",
+ name: "pro5m",
priceId: "price_1R2kybDFVprnAny21Mo1Wjuz",
price: 129,
interval: "month",
@@ -99,7 +99,7 @@ export const STRIPE_PRICES = [
},
},
{
- name: "basic5m-annual",
+ name: "pro5m-annual",
priceId: "price_1RE1ebDFVprnAny2BbHtnuko",
price: 1290,
interval: "year",
@@ -108,7 +108,7 @@ export const STRIPE_PRICES = [
},
},
{
- name: "basic10m",
+ name: "pro10m",
priceId: "price_1R2kzxDFVprnAny2wdMx2Npp",
price: 169,
interval: "month",
@@ -117,7 +117,7 @@ export const STRIPE_PRICES = [
},
},
{
- name: "basic10m-annual",
+ name: "pro10m-annual",
priceId: "price_1RE1fHDFVprnAny2SKY4gFCA",
price: 1690,
interval: "year",
diff --git a/server/src/api/sites/getSites.ts b/server/src/api/sites/getSites.ts
index 5533b9b..ebeb369 100644
--- a/server/src/api/sites/getSites.ts
+++ b/server/src/api/sites/getSites.ts
@@ -1,113 +1,53 @@
-import { and, eq, inArray } from "drizzle-orm";
+import { and, eq } from "drizzle-orm";
import { FastifyReply, FastifyRequest } from "fastify";
import { db } from "../../db/postgres/postgres.js";
-import { member, user, subscription } from "../../db/postgres/schema.js";
+import { member } from "../../db/postgres/schema.js";
import { getSitesUserHasAccessTo } from "../../lib/auth-utils.js";
-import { STRIPE_PRICES } from "../../lib/const.js";
+import { getSubscriptionInner } from "../stripe/getSubscription.js";
// Default event limit for users without an active subscription
-const DEFAULT_EVENT_LIMIT = 20_000;
-
-/**
- * Get subscription event limit for a user
- */
-export async function getUserEventLimit(userId: string): Promise {
- try {
- // Find active subscription
- const userSubscription = await db
- .select()
- .from(subscription)
- .where(
- and(
- eq(subscription.referenceId, userId),
- inArray(subscription.status, ["active", "trialing"])
- )
- )
- .limit(1);
-
- if (!userSubscription.length) {
- return DEFAULT_EVENT_LIMIT;
- }
-
- // Find the plan in STRIPE_PLANS
- const plan = STRIPE_PRICES.find((p) => p.name === userSubscription[0].plan);
- return plan ? plan.limits.events : DEFAULT_EVENT_LIMIT;
- } catch (error) {
- console.error(`Error getting event limit for user ${userId}:`, error);
- return DEFAULT_EVENT_LIMIT;
- }
-}
+const DEFAULT_EVENT_LIMIT = 10_000;
export async function getSites(req: FastifyRequest, reply: FastifyReply) {
try {
// Get sites the user has access to
const sitesData = await getSitesUserHasAccessTo(req);
- // Enhance sites data with usage limit information
+ // Enhance sites data - removing usage limit information for now
const enhancedSitesData = await Promise.all(
sitesData.map(async (site) => {
- // Skip if no organization ID
- if (!site.organizationId) {
- return {
- ...site,
- overMonthlyLimit: false,
- eventLimit: DEFAULT_EVENT_LIMIT,
- isOwner: false,
- };
- }
+ let isOwner = false;
+ let ownerId = "";
- // Get the organization owner
- const orgOwner = await db
- .select({ userId: member.userId })
- .from(member)
- .where(
- and(
- eq(member.organizationId, site.organizationId),
- eq(member.role, "owner")
+ // Determine ownership if organization ID exists
+ if (site.organizationId) {
+ const orgOwnerResult = await db
+ .select({ userId: member.userId })
+ .from(member)
+ .where(
+ and(
+ eq(member.organizationId, site.organizationId),
+ eq(member.role, "owner")
+ )
)
- )
- .limit(1);
+ .limit(1);
- if (!orgOwner.length) {
- return {
- ...site,
- overMonthlyLimit: false,
- eventLimit: DEFAULT_EVENT_LIMIT,
- isOwner: false,
- };
+ if (orgOwnerResult.length > 0) {
+ ownerId = orgOwnerResult[0].userId;
+ isOwner = ownerId === req.user?.id;
+ }
}
- // Check if the current user is the organization owner
- const isOwner = orgOwner[0].userId === req.user?.id;
+ const subscription = await getSubscriptionInner(ownerId);
- // Get the user data to check if they're over limit
- const userData = await db
- .select({
- overMonthlyLimit: user.overMonthlyLimit,
- monthlyEventCount: user.monthlyEventCount,
- })
- .from(user)
- .where(eq(user.id, orgOwner[0].userId))
- .limit(1);
+ const monthlyEventCount = subscription?.monthlyEventCount || 0;
+ const eventLimit = subscription?.eventLimit || DEFAULT_EVENT_LIMIT;
- if (!userData.length) {
- return {
- ...site,
- overMonthlyLimit: false,
- eventLimit: DEFAULT_EVENT_LIMIT,
- isOwner,
- };
- }
-
- // Get the user's event limit from their subscription
- const eventLimit = await getUserEventLimit(orgOwner[0].userId);
-
- // Return site with usage limit info
return {
...site,
- overMonthlyLimit: userData[0].overMonthlyLimit || false,
- monthlyEventCount: userData[0].monthlyEventCount || 0,
+ monthlyEventCount,
eventLimit,
+ overMonthlyLimit: monthlyEventCount > eventLimit,
isOwner,
};
})
diff --git a/server/src/api/stripe/createCheckoutSession.ts b/server/src/api/stripe/createCheckoutSession.ts
new file mode 100644
index 0000000..0182b61
--- /dev/null
+++ b/server/src/api/stripe/createCheckoutSession.ts
@@ -0,0 +1,95 @@
+import { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
+import { stripe } from "../../lib/stripe.js";
+import { db } from "../../db/postgres/postgres.js";
+import { user as userSchema } from "../../db/postgres/schema.js";
+import { eq } from "drizzle-orm";
+
+interface CheckoutRequestBody {
+ priceId: string;
+ successUrl: string;
+ cancelUrl: string;
+}
+
+export async function createCheckoutSession(
+ request: FastifyRequest<{ Body: CheckoutRequestBody }>,
+ reply: FastifyReply
+) {
+ const { priceId, successUrl, cancelUrl } = request.body;
+ const userId = request.user?.id;
+
+ if (!userId) {
+ return reply.status(401).send({ error: "Unauthorized" });
+ }
+
+ if (!priceId || !successUrl || !cancelUrl) {
+ return reply.status(400).send({
+ error: "Missing required parameters: priceId, successUrl, cancelUrl",
+ });
+ }
+
+ try {
+ // 1. Find the user in your database
+ const userResult = await db
+ .select({
+ id: userSchema.id,
+ email: userSchema.email,
+ stripeCustomerId: userSchema.stripeCustomerId,
+ })
+ .from(userSchema)
+ .where(eq(userSchema.id, userId))
+ .limit(1);
+
+ const user = userResult[0];
+
+ if (!user) {
+ return reply.status(404).send({ error: "User not found" });
+ }
+
+ let stripeCustomerId = user.stripeCustomerId;
+
+ // 2. If the user doesn't have a Stripe Customer ID, create one
+ if (!stripeCustomerId) {
+ const customer = await stripe.customers.create({
+ email: user.email,
+ metadata: {
+ userId: user.id, // Link Stripe customer to your internal user ID
+ },
+ });
+ stripeCustomerId = customer.id;
+
+ // 3. Update the user record in your database with the new Stripe Customer ID
+ await db
+ .update(userSchema)
+ .set({ stripeCustomerId: stripeCustomerId })
+ .where(eq(userSchema.id, userId));
+ }
+
+ // 4. Create a Stripe Checkout Session
+ const session = await stripe.checkout.sessions.create({
+ payment_method_types: ["card"],
+ mode: "subscription",
+ customer: stripeCustomerId,
+ line_items: [
+ {
+ price: priceId,
+ quantity: 1,
+ },
+ ],
+ success_url: successUrl, // The user will be redirected here on success
+ cancel_url: cancelUrl, // The user will be redirected here if they cancel
+ // Allow promotion codes
+ allow_promotion_codes: true,
+ // Enable automatic tax calculation if configured in Stripe Tax settings
+ automatic_tax: { enabled: true },
+ });
+
+ // 5. Return the Checkout Session URL
+ return reply.send({ checkoutUrl: session.url });
+ } catch (error: any) {
+ console.error("Stripe Checkout Session Error:", error);
+ return reply.status(500).send({
+ error: "Failed to create Stripe checkout session",
+ details: error.message,
+ });
+ }
+}
diff --git a/server/src/api/stripe/createPortalSession.ts b/server/src/api/stripe/createPortalSession.ts
new file mode 100644
index 0000000..bafc7f6
--- /dev/null
+++ b/server/src/api/stripe/createPortalSession.ts
@@ -0,0 +1,61 @@
+import { FastifyReply, FastifyRequest } from "fastify";
+import { stripe } from "../../lib/stripe.js";
+import { db } from "../../db/postgres/postgres.js";
+import { user as userSchema } from "../../db/postgres/schema.js";
+import { eq } from "drizzle-orm";
+
+interface PortalRequestBody {
+ returnUrl: string;
+}
+
+export async function createPortalSession(
+ request: FastifyRequest<{ Body: PortalRequestBody }>,
+ reply: FastifyReply
+) {
+ const { returnUrl } = request.body;
+ const userId = request.user?.id;
+
+ if (!userId) {
+ return reply.status(401).send({ error: "Unauthorized" });
+ }
+
+ if (!returnUrl) {
+ return reply
+ .status(400)
+ .send({ error: "Missing required parameter: returnUrl" });
+ }
+
+ try {
+ // 1. Find the user in your database
+ const userResult = await db
+ .select({
+ stripeCustomerId: userSchema.stripeCustomerId,
+ })
+ .from(userSchema)
+ .where(eq(userSchema.id, userId))
+ .limit(1);
+
+ const user = userResult[0];
+
+ if (!user || !user.stripeCustomerId) {
+ return reply
+ .status(404)
+ .send({ error: "User or Stripe customer ID not found" });
+ }
+
+ // 2. Create a Stripe Billing Portal Session
+ const portalSession = await stripe.billingPortal.sessions.create({
+ customer: user.stripeCustomerId,
+ return_url: returnUrl, // The user will be redirected here after managing their billing
+ });
+
+ // 3. Return the Billing Portal Session URL
+ return reply.send({ portalUrl: portalSession.url });
+ } catch (error: any) {
+ console.error("Stripe Portal Session Error:", error);
+ return reply.status(500).send({
+ error: "Failed to create Stripe portal session",
+ details: error.message,
+ });
+ }
+}
diff --git a/server/src/api/stripe/getSubscription.ts b/server/src/api/stripe/getSubscription.ts
new file mode 100644
index 0000000..db678a8
--- /dev/null
+++ b/server/src/api/stripe/getSubscription.ts
@@ -0,0 +1,115 @@
+import { FastifyReply, FastifyRequest } from "fastify";
+import { stripe } from "../../lib/stripe.js";
+import { db } from "../../db/postgres/postgres.js";
+import { user as userSchema } from "../../db/postgres/schema.js";
+import { eq } from "drizzle-orm";
+import { STRIPE_PRICES, StripePlan } from "../../lib/const.js";
+import Stripe from "stripe";
+
+// Function to find plan details by price ID
+function findPlanDetails(priceId: string): StripePlan | undefined {
+ return STRIPE_PRICES.find(
+ (plan: StripePlan) =>
+ plan.priceId === priceId ||
+ (plan.annualDiscountPriceId && plan.annualDiscountPriceId === priceId)
+ );
+}
+
+export async function getSubscriptionInner(userId: string) {
+ // 1. Find the user and their Stripe Customer ID
+ const userResult = await db
+ .select({
+ stripeCustomerId: userSchema.stripeCustomerId,
+ monthlyEventCount: userSchema.monthlyEventCount,
+ })
+ .from(userSchema)
+ .where(eq(userSchema.id, userId))
+ .limit(1);
+
+ const user = userResult[0];
+
+ if (!user || !user.stripeCustomerId) {
+ // If no customer ID, they definitely don't have a subscription
+ return null;
+ }
+
+ // 2. List active subscriptions for the customer from Stripe
+ const subscriptions = await stripe.subscriptions.list({
+ customer: user.stripeCustomerId,
+ status: "active", // Only fetch active subscriptions
+ limit: 1, // Users should only have one active subscription in this model
+ expand: ["data.plan.product"], // Expand to get product details if needed
+ });
+
+ if (subscriptions.data.length === 0) {
+ // No active subscription found
+ return null;
+ }
+
+ const sub = subscriptions.data[0];
+ const priceId = sub.items.data[0]?.price.id;
+
+ if (!priceId) {
+ throw new Error("Subscription item price ID not found");
+ }
+
+ // 3. Find corresponding plan details from your constants
+ const planDetails = findPlanDetails(priceId);
+
+ if (!planDetails) {
+ console.error("Plan details not found for price ID:", priceId);
+ // Still return the basic subscription info even if local plan details missing
+ return {
+ id: sub.id,
+ planName: "Unknown Plan", // Indicate missing details
+ status: sub.status,
+ currentPeriodEnd: new Date(sub.current_period_end * 1000),
+ eventLimit: 0, // Unknown limit
+ monthlyEventCount: user.monthlyEventCount,
+ interval: sub.items.data[0]?.price.recurring?.interval ?? "unknown",
+ };
+ }
+
+ // 4. Format and return the subscription data
+ const responseData = {
+ id: sub.id,
+ planName: planDetails.name,
+ status: sub.status,
+ currentPeriodEnd: new Date(sub.current_period_end * 1000), // Convert Unix timestamp to Date
+ eventLimit: planDetails.limits.events,
+ monthlyEventCount: user.monthlyEventCount,
+ interval: sub.items.data[0]?.price.recurring?.interval ?? "unknown",
+ };
+
+ return responseData;
+}
+
+export async function getSubscription(
+ request: FastifyRequest,
+ reply: FastifyReply
+) {
+ const userId = request.user?.id;
+
+ if (!userId) {
+ return reply.status(401).send({ error: "Unauthorized" });
+ }
+
+ try {
+ const responseData = await getSubscriptionInner(userId);
+
+ return reply.send(responseData);
+ } catch (error: any) {
+ console.error("Get Subscription Error:", error);
+ // Handle specific Stripe errors if necessary
+ if (error instanceof Stripe.errors.StripeError) {
+ return reply
+ .status(error.statusCode || 500)
+ .send({ error: error.message });
+ } else {
+ return reply.status(500).send({
+ error: "Failed to fetch subscription details",
+ details: error.message,
+ });
+ }
+ }
+}
diff --git a/server/src/api/stripe/webhook.ts b/server/src/api/stripe/webhook.ts
new file mode 100644
index 0000000..053b663
--- /dev/null
+++ b/server/src/api/stripe/webhook.ts
@@ -0,0 +1,129 @@
+import { FastifyReply, FastifyRequest } from "fastify";
+import { stripe } from "../../lib/stripe.js";
+import { db } from "../../db/postgres/postgres.js";
+import { user as userSchema } from "../../db/postgres/schema.js";
+import { eq } from "drizzle-orm";
+import Stripe from "stripe";
+import dotenv from "dotenv";
+
+dotenv.config();
+
+const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET;
+
+export async function handleWebhook(
+ request: FastifyRequest,
+ reply: FastifyReply
+) {
+ if (!webhookSecret) {
+ console.error("Stripe webhook secret is not configured.");
+ return reply.status(500).send({ error: "Webhook secret not configured." });
+ }
+
+ const sig = request.headers["stripe-signature"];
+ let event: Stripe.Event;
+
+ try {
+ // Use rawBody instead of request.body for signature verification
+ const rawBody = (request.raw as any).body;
+ if (!rawBody) {
+ return reply.status(400).send("Webhook error: No raw body available");
+ }
+
+ event = stripe.webhooks.constructEvent(
+ rawBody,
+ sig as string,
+ webhookSecret
+ );
+ } catch (err: any) {
+ console.error(`Webhook signature verification failed: ${err.message}`);
+ return reply.status(400).send(`Webhook Error: ${err.message}`);
+ }
+
+ // Handle the event
+ switch (event.type) {
+ case "checkout.session.completed":
+ const session = event.data.object as Stripe.Checkout.Session;
+ console.log("Checkout session completed event received:", session.id);
+
+ // If the checkout session was for a subscription
+ if (session.mode === "subscription" && session.customer) {
+ const stripeCustomerId = session.customer as string;
+ const userId = session.metadata?.userId; // Retrieve userId from metadata if you set it
+
+ if (stripeCustomerId) {
+ try {
+ // Check if user already has this customer ID
+ const existingUser = await db
+ .select({ id: userSchema.id })
+ .from(userSchema)
+ .where(eq(userSchema.stripeCustomerId, stripeCustomerId))
+ .limit(1);
+
+ // If no user has this ID, update the user linked via metadata (if available)
+ // Or update based on email if metadata is not reliable
+ if (existingUser.length === 0) {
+ let userToUpdateId: string | null = null;
+
+ if (userId) {
+ userToUpdateId = userId;
+ } else if (session.customer_details?.email) {
+ // Fallback: Find user by email (ensure email is unique in your DB)
+ const userByEmail = await db
+ .select({ id: userSchema.id })
+ .from(userSchema)
+ .where(eq(userSchema.email, session.customer_details.email))
+ .limit(1);
+ if (userByEmail.length > 0) {
+ userToUpdateId = userByEmail[0].id;
+ }
+ }
+
+ if (userToUpdateId) {
+ console.log(
+ `Updating user ${userToUpdateId} with Stripe customer ID ${stripeCustomerId}`
+ );
+ await db
+ .update(userSchema)
+ .set({ stripeCustomerId: stripeCustomerId })
+ .where(eq(userSchema.id, userToUpdateId));
+ } else {
+ console.error(
+ `Could not find user to associate with Stripe customer ID ${stripeCustomerId} from checkout session ${session.id}`
+ );
+ }
+ } else {
+ console.log(
+ `User ${existingUser[0].id} already has Stripe customer ID ${stripeCustomerId}`
+ );
+ }
+ } catch (dbError: any) {
+ console.error(
+ `Database error updating user with Stripe customer ID: ${dbError.message}`
+ );
+ // Decide if you should still return 200 to Stripe or signal an error
+ }
+ }
+ }
+ break;
+
+ // case "customer.subscription.updated":
+ // const subscriptionUpdated = event.data.object as Stripe.Subscription;
+ // console.log("Subscription updated:", subscriptionUpdated.id, subscriptionUpdated.status);
+ // // Potential actions: Update user roles/permissions based on status
+ // break;
+
+ // case "customer.subscription.deleted":
+ // const subscriptionDeleted = event.data.object as Stripe.Subscription;
+ // console.log("Subscription deleted:", subscriptionDeleted.id);
+ // // Potential actions: Update user roles/permissions, mark as unsubscribed
+ // break;
+
+ // ... handle other event types as needed
+
+ default:
+ console.log(`Unhandled event type ${event.type}`);
+ }
+
+ // Return a 200 response to acknowledge receipt of the event
+ reply.send({ received: true });
+}
diff --git a/server/src/api/user/getUserSubscription.ts b/server/src/api/user/getUserSubscription.ts
deleted file mode 100644
index c121a87..0000000
--- a/server/src/api/user/getUserSubscription.ts
+++ /dev/null
@@ -1,84 +0,0 @@
-import { FastifyRequest, FastifyReply } from "fastify";
-import { getSession } from "../../lib/auth-utils.js";
-import { getUserEventLimit } from "../sites/getSites.js";
-import { db } from "../../db/postgres/postgres.js";
-import { user, subscription } from "../../db/postgres/schema.js";
-import { eq, and, inArray } from "drizzle-orm";
-import { STRIPE_PRICES } from "../../lib/const.js";
-
-// Define the plan interface
-interface StripePlan {
- name: string;
- priceId: string;
- interval: string;
- limits: {
- events: number;
- [key: string]: any;
- };
-}
-
-export async function getUserSubscription(
- req: FastifyRequest,
- reply: FastifyReply
-) {
- const session = await getSession(req);
- if (!session) {
- return reply.status(401).send({ error: "Unauthorized" });
- }
-
- try {
- // Get user's monthly event count
- const userData = await db.query.user.findFirst({
- where: eq(user.id, session.user.id),
- columns: {
- monthlyEventCount: true,
- overMonthlyLimit: true,
- },
- });
-
- // Find user's active subscription
- const userSubscription = await db
- .select()
- .from(subscription)
- .where(
- and(
- eq(subscription.referenceId, session.user.id),
- inArray(subscription.status, ["active", "trialing"])
- )
- )
- .limit(1);
-
- // Get plan details
- let subscriptionPlanDetails = null;
- const eventLimit = await getUserEventLimit(session.user.id);
-
- if (userSubscription.length > 0) {
- const subData = userSubscription[0];
- // Find detailed plan information from STRIPE_PRICES
- const planDetails = STRIPE_PRICES.find(
- (plan: StripePlan) => plan.name === subData.plan
- );
-
- subscriptionPlanDetails = {
- ...subData,
- planDetails: planDetails || null,
- };
- }
-
- // Construct the response
- const response = {
- ...subscriptionPlanDetails,
- monthlyEventCount: userData?.monthlyEventCount || 0,
- overMonthlyLimit: userData?.overMonthlyLimit || false,
- monthlyEventLimit: eventLimit,
- };
-
- return reply.status(200).send(response);
- } catch (error) {
- console.error("Error fetching user subscription:", error);
- return reply.status(500).send({
- error: "Failed to fetch subscription details",
- message: error instanceof Error ? error.message : String(error),
- });
- }
-}
diff --git a/server/src/cron/monthly-usage-checker.ts b/server/src/cron/monthly-usage-checker.ts
index afaa7a4..202b852 100644
--- a/server/src/cron/monthly-usage-checker.ts
+++ b/server/src/cron/monthly-usage-checker.ts
@@ -1,12 +1,13 @@
-import { user, member, sites, subscription } from "../db/postgres/schema.js";
+import { user, member, sites } from "../db/postgres/schema.js";
import { clickhouse } from "../db/clickhouse/clickhouse.js";
-import { STRIPE_PRICES } from "../lib/const.js";
+import { STRIPE_PRICES, StripePlan } from "../lib/const.js";
import { eq, inArray, and } from "drizzle-orm";
import { db } from "../db/postgres/postgres.js";
import { processResults } from "../api/analytics/utils.js";
+import { stripe } from "../lib/stripe.js";
// Default event limit for users without an active subscription
-const DEFAULT_EVENT_LIMIT = 20_000;
+const DEFAULT_EVENT_LIMIT = 10_000;
// Global set to track site IDs that have exceeded their monthly limits
export const sitesOverLimit = new Set();
@@ -53,42 +54,64 @@ async function getSiteIdsForUser(userId: string): Promise {
}
/**
- * Gets event limit for a user based on their subscription plan
+ * Gets event limit and billing period start date for a user based on their Stripe subscription.
+ * Fetches directly from Stripe if the user has a stripeCustomerId.
* @returns [eventLimit, periodStartDate]
*/
-async function getUserSubscriptionInfo(
- userId: string
-): Promise<[number, string | null]> {
- try {
- // Find active subscription
- const userSubscription = await db
- .select()
- .from(subscription)
- .where(
- and(
- eq(subscription.referenceId, userId),
- inArray(subscription.status, ["active", "trialing"])
- )
- )
- .limit(1);
+async function getUserSubscriptionInfo(userData: {
+ id: string;
+ stripeCustomerId: string | null;
+}): Promise<[number, string | null]> {
+ if (!userData.stripeCustomerId) {
+ // No Stripe customer ID, use default limit and start of current month
+ return [DEFAULT_EVENT_LIMIT, getStartOfMonth()];
+ }
- if (!userSubscription.length) {
- return [DEFAULT_EVENT_LIMIT, null];
+ try {
+ // Fetch active subscriptions for the customer from Stripe
+ const subscriptions = await stripe.subscriptions.list({
+ customer: userData.stripeCustomerId,
+ status: "active",
+ limit: 1,
+ });
+
+ if (subscriptions.data.length === 0) {
+ // No active subscription, use default limit and start of current month
+ return [DEFAULT_EVENT_LIMIT, getStartOfMonth()];
}
- // Find the plan in STRIPE_PLANS
- const plan = STRIPE_PRICES.find((p) => p.name === userSubscription[0].plan);
- const eventLimit = plan ? plan.limits.events : DEFAULT_EVENT_LIMIT;
+ const sub = subscriptions.data[0];
+ const priceId = sub.items.data[0]?.price.id;
- // Get period start date - if not available, use first day of month
- const periodStart = userSubscription[0].periodStart
- ? new Date(userSubscription[0].periodStart).toISOString().split("T")[0]
+ if (!priceId) {
+ console.error(
+ `Subscription item price ID not found for user ${userData.id}, sub ${sub.id}`
+ );
+ return [DEFAULT_EVENT_LIMIT, getStartOfMonth()];
+ }
+
+ // Find corresponding plan details from constants
+ const planDetails = STRIPE_PRICES.find(
+ (plan: StripePlan) =>
+ plan.priceId === priceId ||
+ (plan.annualDiscountPriceId && plan.annualDiscountPriceId === priceId)
+ );
+
+ const eventLimit = planDetails
+ ? planDetails.limits.events
+ : DEFAULT_EVENT_LIMIT;
+ const periodStart = sub.current_period_start
+ ? new Date(sub.current_period_start * 1000).toISOString().split("T")[0]
: getStartOfMonth();
return [eventLimit, periodStart];
- } catch (error) {
- console.error(`Error getting subscription info for user ${userId}:`, error);
- return [DEFAULT_EVENT_LIMIT, null];
+ } catch (error: any) {
+ console.error(
+ `Error fetching Stripe subscription info for user ${userData.id}:`,
+ error
+ );
+ // Fallback to default limit and current month start on Stripe API error
+ return [DEFAULT_EVENT_LIMIT, getStartOfMonth()];
}
}
@@ -103,7 +126,7 @@ async function getMonthlyPageviews(
return 0;
}
- // If no startDate is provided (no subscription), default to start of month
+ // If no startDate is provided (e.g., no subscription), default to start of month
const periodStart = startDate || getStartOfMonth();
try {
@@ -139,8 +162,14 @@ export async function updateUsersMonthlyUsage() {
// Clear the previous list of sites over their limit
sitesOverLimit.clear();
- // Get all users
- const users = await db.select().from(user);
+ // Get all users with their Stripe customer ID
+ const users = await db
+ .select({
+ id: user.id,
+ email: user.email,
+ stripeCustomerId: user.stripeCustomerId,
+ })
+ .from(user);
for (const userData of users) {
try {
@@ -154,10 +183,10 @@ export async function updateUsersMonthlyUsage() {
// Get user's subscription information (limit and period start)
const [eventLimit, periodStart] = await getUserSubscriptionInfo(
- userData.id
+ userData
);
- // Get monthly pageview count from ClickHouse using the subscription period
+ // Get monthly pageview count from ClickHouse using the billing period start date
const pageviewCount = await getMonthlyPageviews(siteIds, periodStart);
// Check if over limit and update global set
diff --git a/server/src/db/postgres/schema.ts b/server/src/db/postgres/schema.ts
index 0e033f6..d807cc5 100644
--- a/server/src/db/postgres/schema.ts
+++ b/server/src/db/postgres/schema.ts
@@ -231,20 +231,3 @@ export const session = pgTable(
unique("session_token_unique").on(table.token),
]
);
-
-export const subscription = pgTable("subscription", {
- id: text().primaryKey().notNull(),
- plan: text().notNull(),
- referenceId: text().notNull(),
- stripeCustomerId: text(),
- stripeSubscriptionId: text(),
- status: text().notNull(),
- periodStart: timestamp({ mode: "string" }),
- periodEnd: timestamp({ mode: "string" }),
- cancelAtPeriodEnd: boolean(),
- seats: integer(),
- trialStart: timestamp({ mode: "string" }),
- trialEnd: timestamp({ mode: "string" }),
- createdAt: timestamp({ mode: "string" }).defaultNow().notNull(),
- updatedAt: timestamp({ mode: "string" }).defaultNow().notNull(),
-});
diff --git a/server/src/index.ts b/server/src/index.ts
index bc42e5d..d1c0073 100644
--- a/server/src/index.ts
+++ b/server/src/index.ts
@@ -31,7 +31,6 @@ import { getSiteHasData } from "./api/sites/getSiteHasData.js";
import { getSites } from "./api/sites/getSites.js";
import { createAccount } from "./api/user/createAccount.js";
import { getUserOrganizations } from "./api/user/getUserOrganizations.js";
-import { getUserSubscription } from "./api/user/getUserSubscription.js";
import { listOrganizationMembers } from "./api/user/listOrganizationMembers.js";
import { initializeCronJobs } from "./cron/index.js";
import { initializeClickhouse } from "./db/clickhouse/clickhouse.js";
@@ -46,6 +45,12 @@ import { publicSites } from "./lib/publicSites.js";
import { getSiteIsPublic } from "./api/sites/getSiteIsPublic.js";
import { getUserSessionCount } from "./api/analytics/getUserSessionCount.js";
+// Import Stripe handlers
+import { createCheckoutSession } from "./api/stripe/createCheckoutSession.js";
+import { createPortalSession } from "./api/stripe/createPortalSession.js";
+import { getSubscription } from "./api/stripe/getSubscription.js";
+import { handleWebhook } from "./api/stripe/webhook.js";
+
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
@@ -102,7 +107,14 @@ server.register(
{ auth: auth! }
);
-const PUBLIC_ROUTES = ["/health", "/track", "/script", "/auth", "/api/auth"];
+const PUBLIC_ROUTES: string[] = [
+ "/health",
+ "/track",
+ "/script",
+ "/auth",
+ "/api/auth",
+ "/api/stripe/webhook", // Add webhook to public routes
+];
// Define analytics routes that can be public
const ANALYTICS_ROUTES = [
@@ -204,7 +216,16 @@ server.get(
listOrganizationMembers
);
server.get("/user/organizations", getUserOrganizations);
-server.get("/user/subscription", getUserSubscription);
+
+// Stripe Routes
+server.post("/api/stripe/create-checkout-session", createCheckoutSession);
+server.post("/api/stripe/create-portal-session", createPortalSession);
+server.get("/api/stripe/subscription", getSubscription);
+server.post(
+ "/api/stripe/webhook",
+ { config: { rawBody: true } },
+ handleWebhook
+); // Use rawBody parser config for webhook
server.post("/track", trackEvent);
diff --git a/server/src/lib/auth.ts b/server/src/lib/auth.ts
index 122cb3a..c13ceb5 100644
--- a/server/src/lib/auth.ts
+++ b/server/src/lib/auth.ts
@@ -7,7 +7,6 @@ import { db } from "../db/postgres/postgres.js";
import { IS_CLOUD, STRIPE_PRICES } from "./const.js";
import * as schema from "../db/postgres/schema.js";
import { eq } from "drizzle-orm";
-import { stripe } from "@better-auth/stripe";
import Stripe from "stripe";
dotenv.config();
@@ -25,15 +24,6 @@ const pluginList = IS_CLOUD
// Set the creator role to owner
creatorRole: "owner",
}),
- stripe({
- stripeClient,
- stripeWebhookSecret: process.env.STRIPE_WEBHOOK_SECRET!,
- createCustomerOnSignUp: true,
- subscription: {
- enabled: true,
- plans: STRIPE_PRICES,
- },
- }),
]
: [
username(),
diff --git a/server/src/lib/const.ts b/server/src/lib/const.ts
index 853621c..73b0dfb 100644
--- a/server/src/lib/const.ts
+++ b/server/src/lib/const.ts
@@ -4,10 +4,21 @@ dotenv.config();
export const IS_CLOUD = process.env.CLOUD === "true";
-export const STRIPE_PRICES = [
+// Define a type for the plan objects
+export interface StripePlan {
+ priceId: string;
+ name: string;
+ interval: "month" | "year";
+ limits: {
+ events: number;
+ };
+ annualDiscountPriceId?: string; // Make this optional
+}
+
+export const STRIPE_PRICES: StripePlan[] = [
{
priceId: "price_1R1fIVDFVprnAny2yJtRRPBm",
- name: "basic100k",
+ name: "pro100k",
interval: "month",
limits: {
events: 100_000,
@@ -15,7 +26,7 @@ export const STRIPE_PRICES = [
},
{
priceId: "price_1R2l2KDFVprnAny2iZr5gFLe",
- name: "basic100k-annual",
+ name: "pro100k-annual",
interval: "year",
limits: {
events: 100_000,
@@ -23,7 +34,7 @@ export const STRIPE_PRICES = [
},
{
priceId: "price_1R1fKJDFVprnAny2mfiBjkAQ",
- name: "basic250k",
+ name: "pro250k",
interval: "month",
limits: {
events: 250_000,
@@ -31,14 +42,14 @@ export const STRIPE_PRICES = [
},
{
priceId: "price_1R2lJIDFVprnAny22zUvjg5o",
- name: "basic250k-annual",
+ name: "pro250k-annual",
interval: "year",
limits: {
events: 250_000,
},
},
{
- name: "basic500k",
+ name: "pro500k",
priceId: "price_1R1fQlDFVprnAny2WwNdiRgT",
interval: "month",
limits: {
@@ -46,7 +57,7 @@ export const STRIPE_PRICES = [
},
},
{
- name: "basic500k-annual",
+ name: "pro500k-annual",
priceId: "price_1R2lKIDFVprnAny27wXUAy2D",
interval: "year",
limits: {
@@ -54,7 +65,7 @@ export const STRIPE_PRICES = [
},
},
{
- name: "basic1m",
+ name: "pro1m",
priceId: "price_1R1fR2DFVprnAny28tPEQAwh",
interval: "month",
limits: {
@@ -62,7 +73,7 @@ export const STRIPE_PRICES = [
},
},
{
- name: "basic1m-annual",
+ name: "pro1m-annual",
priceId: "price_1R2lKtDFVprnAny2Xl98rgu4",
interval: "year",
limits: {
@@ -70,7 +81,7 @@ export const STRIPE_PRICES = [
},
},
{
- name: "basic2m",
+ name: "pro2m",
priceId: "price_1R1fRMDFVprnAny24AMo0Vuu",
interval: "month",
limits: {
@@ -78,7 +89,7 @@ export const STRIPE_PRICES = [
},
},
{
- name: "basic2m-annual",
+ name: "pro2m-annual",
priceId: "price_1RE1bQDFVprnAny2ELKQS79d",
interval: "year",
limits: {
@@ -86,7 +97,7 @@ export const STRIPE_PRICES = [
},
},
{
- name: "basic5m",
+ name: "pro5m",
priceId: "price_1R2kybDFVprnAny21Mo1Wjuz",
interval: "month",
limits: {
@@ -94,7 +105,7 @@ export const STRIPE_PRICES = [
},
},
{
- name: "basic5m-annual",
+ name: "pro5m-annual",
priceId: "price_1RE1ebDFVprnAny2BbHtnuko",
interval: "year",
limits: {
@@ -102,7 +113,7 @@ export const STRIPE_PRICES = [
},
},
{
- name: "basic10m",
+ name: "pro10m",
priceId: "price_1R2kzxDFVprnAny2wdMx2Npp",
interval: "month",
limits: {
@@ -110,7 +121,7 @@ export const STRIPE_PRICES = [
},
},
{
- name: "basic10m-annual",
+ name: "pro10m-annual",
priceId: "price_1RE1fHDFVprnAny2SKY4gFCA",
interval: "year",
limits: {
diff --git a/server/src/lib/stripe.ts b/server/src/lib/stripe.ts
new file mode 100644
index 0000000..afdb90e
--- /dev/null
+++ b/server/src/lib/stripe.ts
@@ -0,0 +1,17 @@
+import Stripe from "stripe";
+import dotenv from "dotenv";
+
+dotenv.config();
+
+const secretKey = process.env.STRIPE_SECRET_KEY;
+
+if (!secretKey) {
+ throw new Error(
+ "Stripe secret key is not defined in environment variables. Please set STRIPE_SECRET_KEY."
+ );
+}
+
+export const stripe = new Stripe(secretKey, {
+ // apiVersion: "2024-06-20", // Use the latest API version - Removed to use SDK default
+ typescript: true, // Enable TypeScript support
+});