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.
This commit is contained in:
Bill Yang 2025-04-15 23:31:41 -07:00 committed by GitHub
parent 85a82b233d
commit c631b19dd9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
28 changed files with 1068 additions and 1477 deletions

View file

@ -6,6 +6,5 @@ import { UsageBanners } from "./UsageBanners";
export function Header() {
const { user } = userStore();
return null;
// return <div className="flex flex-col">{user && <UsageBanners />}</div>;
return <div className="flex flex-col p-3">{user && <UsageBanners />}</div>;
}

View file

@ -57,11 +57,7 @@ export function UsageBanners() {
<AlertDescription className="text-sm">
Upgrade your plan to continue collecting analytics.
</AlertDescription>
<Button
variant="outline"
className="text-neutral-100 border-white/20 hover:border-white/30 py-1 h-auto text-sm"
asChild
>
<Button variant="default" asChild>
<Link href="/settings/subscription">
Upgrade Plan <ArrowRight className="ml-1 h-3 w-3" />
</Link>
@ -101,12 +97,11 @@ export function UsageBanners() {
Consider upgrading your plan to avoid interruptions.
</AlertDescription>
<Button
variant="outline"
className="bg-white hover:bg-white/90 text-neutral-100 border-white/20 hover:border-white/30 py-1 h-auto text-sm"
asChild
>
<Link href="/settings/subscription">
View Plans <ArrowRight className="ml-1 h-3 w-3" />
Upgrade Plan <ArrowRight className="ml-1 h-3 w-3" />
</Link>
</Button>
</div>

View file

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

View file

@ -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<void>;
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<string | null>(null);
// State to track billing interval preference
const [isAnnual, setIsAnnual] = useState<boolean>(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 (
<Dialog
open={showUpgradeDialog}
onOpenChange={(open) => {
setShowUpgradeDialog(open);
if (!open) setResumingPlan(null);
}}
>
<DialogContent className="max-w-3xl">
<DialogHeader>
<DialogTitle>
{activeSubscription?.cancelAtPeriodEnd
? "Resume Subscription"
: "Change Your Plan"}
</DialogTitle>
<DialogDescription className="py-4">
{activeSubscription?.cancelAtPeriodEnd
? "Select a plan to resume your subscription. Your current plan is highlighted."
: "Select a plan to switch to"}
</DialogDescription>
</DialogHeader>
{actionError && (
<Alert variant="destructive" className="mb-4">
<AlertCircle className="h-4 w-4" />
<AlertTitle>Error</AlertTitle>
<AlertDescription>{actionError}</AlertDescription>
</Alert>
)}
{resumingPlan && (
<Alert className="mb-4">
<AlertCircle className="h-4 w-4" />
<AlertTitle>Resuming Subscription</AlertTitle>
<AlertDescription>
Your current plan is highlighted. Click "Select" to resume this
plan or choose a different one.
</AlertDescription>
</Alert>
)}
{/* Billing toggle buttons */}
<div className="flex justify-center mb-6">
<div className="bg-neutral-100 dark:bg-neutral-800 p-1 rounded-full inline-flex relative">
<button
onClick={() => 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
</button>
<div className="relative">
<button
onClick={() => 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
</button>
<Badge className="absolute -top-2 -right-2 bg-emerald-500 text-white border-0 pointer-events-none">
2 months free
</Badge>
</div>
</div>
</div>
<div className="grid gap-4">
{/* Pro Plans */}
<div>
<h3 className="font-medium mb-3 flex items-center">
<Shield className="h-4 w-4 mr-2 text-green-500" />
Pro Plans
</h3>
<div className="space-y-3">
{filteredPlans.map((plan) => (
<Card
key={plan.priceId}
className={`cursor-pointer hover:shadow-md transition-shadow ${
(activeSubscription?.plan === plan.name &&
!activeSubscription?.cancelAtPeriodEnd) ||
resumingPlan === plan.name
? "ring-2 ring-green-400"
: ""
}`}
>
<CardContent className="p-3">
<div className="flex items-center justify-between">
<div>
<h3 className="font-bold">
{plan.limits.events.toLocaleString()} events
{isAnnual && (
<Badge className="ml-2 bg-emerald-500 text-white border-0 text-xs">
Save 17%
</Badge>
)}
</h3>
<p className="text-sm text-gray-500 dark:text-gray-400">
${plan.price} / {plan.interval}
</p>
</div>
<Button
size="sm"
variant={
(activeSubscription?.plan === plan.name &&
!activeSubscription?.cancelAtPeriodEnd) ||
(resumingPlan === plan.name &&
resumingPlan === activeSubscription?.plan)
? "outline"
: "default"
}
onClick={() => {
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"}
</Button>
</div>
</CardContent>
</Card>
))}
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setShowUpgradeDialog(false)}>
Cancel
</Button>
<Button
variant="outline"
onClick={() => {
router.push("/subscribe");
setShowUpgradeDialog(false);
}}
>
View All Plans
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View file

@ -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<void>;
handleResumeSubscription: () => Promise<void>;
handleShowUpgradeOptions: () => void;
upgradePlans: any[];
}
export function CurrentPlanCard({
activeSubscription,
currentPlan,
currentUsage,
eventLimit,
usagePercentage,
isProcessing,
handleCancelSubscription,
handleResumeSubscription,
handleShowUpgradeOptions,
upgradePlans,
}: CurrentPlanCardProps) {
return (
<Card>
<CardHeader className="pb-3">
<div className="flex justify-between items-start">
<div>
<CardTitle className="text-xl">Current Plan</CardTitle>
<CardDescription>Your current subscription details</CardDescription>
</div>
<Badge
variant={
activeSubscription.cancelAtPeriodEnd
? "outline"
: activeSubscription?.status === "active"
? "default"
: activeSubscription?.status === "trialing"
? "outline"
: "destructive"
}
>
{activeSubscription.cancelAtPeriodEnd
? "Cancels Soon"
: activeSubscription?.status === "active"
? "Active"
: activeSubscription?.status === "trialing"
? "Trial"
: activeSubscription?.status === "canceled"
? "Canceled"
: activeSubscription?.status}
</Badge>
</div>
</CardHeader>
<CardContent>
<div className="space-y-6">
<div className="grid gap-4 md:grid-cols-2">
<div>
<h3 className="font-medium">Plan</h3>
<p className="text-lg font-bold">{currentPlan?.name}</p>
<p className="text-sm text-gray-500 dark:text-gray-400">
{currentPlan?.price}/{currentPlan?.interval}
</p>
</div>
<div>
<h3 className="font-medium">
{activeSubscription.cancelAtPeriodEnd ||
activeSubscription.status === "canceled"
? "Ends On"
: "Renewal Date"}
</h3>
<p className="text-lg font-bold">
{formatDate(activeSubscription?.periodEnd)}
</p>
{activeSubscription?.cancelAt &&
!activeSubscription.cancelAtPeriodEnd && (
<p className="text-sm text-red-500">
Cancels on {formatDate(activeSubscription?.cancelAt)}
</p>
)}
</div>
</div>
{activeSubscription?.trialEnd &&
new Date(
activeSubscription.trialEnd instanceof Date
? activeSubscription.trialEnd
: String(activeSubscription.trialEnd)
) > new Date() && (
<Alert>
<AlertCircle className="h-4 w-4" />
<AlertTitle>Trial Period</AlertTitle>
<AlertDescription>
Your trial ends on {formatDate(activeSubscription?.trialEnd)}.
You'll be charged afterward unless you cancel.
</AlertDescription>
</Alert>
)}
<Separator />
{/* Usage section */}
<div>
<h3 className="font-medium mb-2">Usage</h3>
<div className="space-y-4">
<div>
<div className="flex justify-between mb-1">
<span className="text-sm">Events</span>
<span className="text-sm">
{currentUsage.toLocaleString()} /{" "}
{eventLimit.toLocaleString()}
</span>
</div>
<Progress value={usagePercentage} />
</div>
</div>
</div>
</div>
</CardContent>
<CardFooter className="flex justify-between">
{activeSubscription.cancelAtPeriodEnd ? (
<Button
variant="default"
onClick={handleResumeSubscription}
disabled={isProcessing}
>
{isProcessing ? "Processing..." : <>Resume Subscription</>}
</Button>
) : (
<Button
variant="outline"
onClick={handleCancelSubscription}
disabled={isProcessing}
>
{isProcessing ? (
"Processing..."
) : (
<>
Cancel Subscription <X className="ml-2 h-4 w-4" />
</>
)}
</Button>
)}
{/* Only show change plan button if there are other plans available */}
{upgradePlans.length > 0 && (
<Button onClick={handleShowUpgradeOptions} disabled={isProcessing}>
{isProcessing ? (
"Processing..."
) : (
<>
Change Plan <ArrowRight className="ml-2 h-4 w-4" />
</>
)}
</Button>
)}
</CardFooter>
</Card>
);
}

View file

@ -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 (
<Dialog open={showConfigError} onOpenChange={setShowConfigError}>
<DialogContent>
<DialogHeader>
<DialogTitle>
{errorType === "cancel"
? "Subscription Cancellation Unavailable"
: "Stripe Checkout Error"}
</DialogTitle>
<DialogDescription className="py-4">
{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."}
</DialogDescription>
</DialogHeader>
<DialogFooter className="flex justify-between">
<Button onClick={() => setShowConfigError(false)}>Close</Button>
{errorType === "resume" ? (
<Button
variant="default"
onClick={() => {
router.push("/subscribe");
setShowConfigError(false);
}}
>
View Plans
</Button>
) : (
<Button
variant="outline"
onClick={() => {
router.push("/contact");
setShowConfigError(false);
}}
>
Contact Support
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View file

@ -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 (
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle>Free Plan</CardTitle>
<CardDescription>
You are currently on the Free Plan. Upgrade to unlock more events.
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-6">
<div className="space-y-2">
<h3 className="font-medium mb-2">Usage</h3>
<div className="space-y-4">
<div>
<div className="flex justify-between mb-1">
<span className="text-sm">Events</span>
<span className="text-sm">
{currentUsage.toLocaleString()} /{" "}
{DEFAULT_EVENT_LIMIT.toLocaleString()}
</span>
</div>
<Progress
value={Math.min(
(currentUsage / DEFAULT_EVENT_LIMIT) * 100,
100
)}
/>
</div>
</div>
</div>
</div>
</CardContent>
<CardFooter>
<Button onClick={() => router.push("/subscribe")} variant={"success"}>
Upgrade Plan <ArrowRight className="ml-2 h-4 w-4" />
</Button>
</CardFooter>
</Card>
<PlanFeaturesCard currentPlan={getPlanDetails("free")} />
</div>
);
}

View file

@ -37,7 +37,7 @@ export function PlanFeaturesCard({ currentPlan }: PlanFeaturesCardProps) {
</CardHeader>
<CardContent>
<ul className="space-y-2">
{currentPlan?.features.map((feature, i) => (
{currentPlan?.features?.map((feature, i) => (
<li key={i} className="flex items-start">
<Check className="mr-2 h-5 w-5 text-green-500 shrink-0" />
<span>{feature}</span>

View file

@ -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<string | null>(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 (
<div className="space-y-6">
{actionError && <Alert variant="destructive">{actionError}</Alert>}
<Card>
<CardContent>
<div className="space-y-6 mt-3">
<div className="flex justify-between items-start">
<div className="space-y-1">
<p className="text-3xl font-bold">
{currentPlanDetails?.name || activeSubscription.planName}
</p>
<p className="text text-gray-300">{getFormattedPrice()}</p>
{isAnnualPlan && (
<div className="mt-2 text-sm text-emerald-400">
<p>You save by paying annually (2 months free)</p>
</div>
)}
<p className="text-neutral-400 text-sm">
{formatRenewalDate()}
</p>
</div>
<Button
variant="outline"
onClick={handleManageSubscription}
disabled={isProcessing}
>
{isProcessing ? "Processing..." : "Change Plan"}
</Button>
</div>
<div className="space-y-2">
<h3 className="font-medium mb-2">Usage</h3>
<div className="space-y-4">
<div>
<div className="flex justify-between mb-1">
<span className="text-sm">Events</span>
<span className="text-sm">
{currentUsage.toLocaleString()} /{" "}
{eventLimit.toLocaleString()}
</span>
</div>
<Progress value={usagePercentage} />
</div>
</div>
</div>
{isAnnualPlan && (
<div className="pt-2 pb-0 px-3 bg-emerald-50 dark:bg-emerald-900/20 rounded-md border border-emerald-100 dark:border-emerald-800">
<p className="text-sm text-emerald-700 dark:text-emerald-300 py-2">
<strong>Annual Billing:</strong> You're on annual billing
which saves you money compared to monthly billing. Your
subscription will renew once per year on{" "}
{formatDate(activeSubscription.currentPeriodEnd)}.
</p>
</div>
)}
</div>
</CardContent>
</Card>
{/* Conditionally render PlanFeaturesCard only when details are available */}
{!isLoading && currentPlanDetails && (
<PlanFeaturesCard currentPlan={currentPlanDetails as any} />
)}
</div>
);
}

View file

@ -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<string | null>(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 (
<div className="container py-10 max-w-5xl mx-auto">
<div className="flex justify-between items-center mb-8">
<div className=" py-2">
<div className="flex justify-between items-center mb-4">
<div>
<h1 className="text-3xl font-bold tracking-tight">Subscription</h1>
<p className="text-gray-500 dark:text-gray-400">
Manage your subscription and billing information
</p>
</div>
<Button variant="outline" onClick={() => router.push("/subscribe")}>
View Plans
</Button>
</div>
{isLoading ? (
@ -260,226 +35,11 @@ export default function SubscriptionPage() {
</div>
</CardContent>
</Card>
) : errorMessage ? (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertTitle>Error</AlertTitle>
<AlertDescription>{errorMessage}</AlertDescription>
</Alert>
) : !activeSubscription?.plan ? (
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle>Free Plan</CardTitle>
<CardDescription>
You are currently on the Free Plan. Upgrade to unlock premium
features.
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-6">
<div className="grid gap-4 md:grid-cols-2">
<div>
<h3 className="font-medium">Plan</h3>
<p className="text-lg font-bold">Free</p>
<p className="text-sm text-gray-500 dark:text-gray-400">
$0/month
</p>
</div>
<div>
<h3 className="font-medium">Renewal Date</h3>
<p className="text-lg font-bold">Never expires</p>
</div>
</div>
<div className="space-y-2">
<h3 className="font-medium mb-2">Usage</h3>
<div className="space-y-4">
<div>
<div className="flex justify-between mb-1">
<span className="text-sm">Events</span>
<span className="text-sm">
{currentUsage.toLocaleString()} /{" "}
{DEFAULT_EVENT_LIMIT.toLocaleString()}
</span>
</div>
<Progress
value={(currentUsage / DEFAULT_EVENT_LIMIT) * 100}
/>
</div>
</div>
</div>
</div>
</CardContent>
<CardFooter>
<Button onClick={() => router.push("/subscribe")}>
Upgrade Plan <ArrowRight className="ml-2 h-4 w-4" />
</Button>
</CardFooter>
</Card>
<PlanFeaturesCard currentPlan={getPlanDetails("free")} />
<HelpSection router={router} />
</div>
) : !activeSubscription ? (
<FreePlan />
) : (
<div className="space-y-6">
{/* Current Plan */}
<Card>
<CardHeader>
<div className="flex justify-between items-start">
<div>
<CardTitle>
{currentPlan?.name || "Current Plan"}
{isAnnualPlan && (
<Badge className="ml-2 bg-emerald-500 text-white">
Annual
</Badge>
)}
{activeSubscription.cancelAtPeriodEnd && (
<Badge className="ml-2 bg-orange-500 text-white">
Canceling
</Badge>
)}
</CardTitle>
<CardDescription>
{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."}
</CardDescription>
</div>
<div>
{activeSubscription.status === "active" && (
<Button
variant="outline"
onClick={handleShowUpgradeOptions}
disabled={isProcessing}
>
Change Plan
</Button>
)}
</div>
</div>
</CardHeader>
<CardContent>
<div className="space-y-6">
<div className="grid gap-4 md:grid-cols-2">
<div>
<h3 className="font-medium">Plan</h3>
<p className="text-lg font-bold">{currentPlan?.name}</p>
<p className="text-sm text-gray-500 dark:text-gray-400">
{getFormattedPrice()}
</p>
{isAnnualPlan && (
<div className="mt-2 text-sm text-emerald-600 dark:text-emerald-400">
<p>You save by paying annually (2 months free)</p>
</div>
)}
</div>
<div>
<h3 className="font-medium">Renewal Date</h3>
<p className="text-lg font-bold">{formatRenewalDate()}</p>
<p className="text-sm text-gray-500 dark:text-gray-400">
{activeSubscription.cancelAtPeriodEnd
? "Your subscription will not renew after this date"
: isAnnualPlan
? "Your plan renews once per year"
: "Your plan renews monthly"}
</p>
</div>
</div>
<div className="space-y-2">
<h3 className="font-medium mb-2">Usage</h3>
<div className="space-y-4">
<div>
<div className="flex justify-between mb-1">
<span className="text-sm">Events</span>
<span className="text-sm">
{currentUsage.toLocaleString()} /{" "}
{eventLimit.toLocaleString()}
</span>
</div>
<Progress value={usagePercentage} />
</div>
</div>
</div>
{/* Billing Cycle Explanation */}
{isAnnualPlan && (
<div className="pt-2 pb-0 px-3 bg-emerald-50 dark:bg-emerald-900/20 rounded-md border border-emerald-100 dark:border-emerald-800">
<p className="text-sm text-emerald-700 dark:text-emerald-300 py-2">
<strong>Annual Billing:</strong> You're on annual billing
which saves you money compared to monthly billing. Your
subscription will renew once per year on{" "}
{formatDate(activeSubscription.periodEnd)}.
</p>
</div>
)}
</div>
</CardContent>
<CardFooter>
{activeSubscription.status === "active" ? (
activeSubscription.cancelAtPeriodEnd ? (
<Button
onClick={handleResumeSubscription}
disabled={isProcessing}
className="text-emerald-500 hover:text-emerald-600"
>
{isProcessing ? "Processing..." : "Resume Subscription"}
</Button>
) : (
<Button
variant="outline"
onClick={handleCancelSubscription}
disabled={isProcessing}
className="text-red-500 hover:text-red-600"
>
{isProcessing ? "Processing..." : "Cancel Subscription"}
</Button>
)
) : (
<Button
onClick={handleResumeSubscription}
disabled={isProcessing}
>
{isProcessing ? "Processing..." : "Resume Subscription"}
</Button>
)}
</CardFooter>
</Card>
{/* Plan Features */}
<PlanFeaturesCard currentPlan={currentPlan} />
</div>
<ProPlan />
)}
{/* Help section */}
<HelpSection router={router} />
{/* Error dialog */}
<ErrorDialog
showConfigError={showConfigError}
setShowConfigError={setShowConfigError}
errorType={errorType}
router={router}
/>
{/* Change plan dialog */}
<ChangePlanDialog
showUpgradeDialog={showUpgradeDialog}
setShowUpgradeDialog={setShowUpgradeDialog}
actionError={actionError}
upgradePlans={upgradePlans}
activeSubscription={activeSubscription}
isProcessing={isProcessing}
handleUpgradeSubscription={handleUpgradeSubscription}
router={router}
/>
</div>
);
}

View file

@ -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<string, PlanTemplate> = {
@ -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: <Clock className="h-5 w-5" />,
},
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: <Shield className="h-5 w-5" />,

View file

@ -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<SubscriptionData>({
queryKey: ["stripe-subscription"],
queryFn: fetchSubscription,
staleTime: 5 * 60 * 1000, // 5 minutes
retry: false,
});
return { data, isLoading, error, refetch };
}

View file

@ -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<number>(0); // Default to 20k (index 0)
const [selectedPrice, setSelectedPrice] = useState<StripePrice | null>(null);
const [isAnnual, setIsAnnual] = useState<boolean>(false);
const [isAnnual, setIsAnnual] = useState<boolean>(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<void> {
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() {
<p className="text-lg text-neutral-600 dark:text-neutral-400 mb-6">
Find the perfect plan to track your site's performance
</p>
{/* Billing toggle buttons */}
<div className="flex justify-center mb-8 mt-10">
<div className="bg-neutral-100 dark:bg-neutral-800 p-1 rounded-full inline-flex relative">
<button
onClick={() => 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
</button>
<div className="relative">
<button
onClick={() => 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
</button>
<Badge className="absolute -top-2 -right-2 bg-emerald-500 text-white border-0 pointer-events-none">
2 months free
</Badge>
</div>
</div>
</div>
</div>
<div className="mb-12 max-w-3xl mx-auto p-6 bg-white dark:bg-neutral-900 rounded-xl shadow-sm border border-neutral-100 dark:border-neutral-800">
<div className="mb-6">
<h2 className="text-xl font-medium mb-4">
How many events do you need?
</h2>
<div className="flex justify-between mb-4">
<span className="text-neutral-600 dark:text-neutral-400">
Events per month
</span>
<span className="font-bold text-lg bg-clip-text text-transparent bg-gradient-to-r from-green-500 to-emerald-400">
{eventLimit.toLocaleString()}
</span>
</div>
</div>
<Slider
defaultValue={[0]} // Default to index 0 (20k)
max={EVENT_TIERS.length - 1}
min={0}
step={1}
onValueChange={handleSliderChange}
className="mb-6"
/>
<div className="flex justify-between text-xs text-neutral-500">
{EVENT_TIERS.map((tier, index) => (
<span
key={index}
className={
eventLimitIndex === index ? "font-bold text-emerald-400" : ""
}
>
{tier.toLocaleString()}
</span>
))}
</div>
</div>
<div className="grid gap-8 md:grid-cols-3 max-w-6xl mx-auto">
<div className="grid gap-8 md:grid-cols-[300px_auto] max-w-4xl mx-auto">
{plans.map((plan) => (
<div key={plan.id} className="transition-all duration-300 h-full">
<Card
@ -428,28 +299,105 @@ export default function Subscribe() {
<div className={`${plan.color} h-3 w-full`}></div>
<CardHeader className="pb-4">
<CardTitle className="text-xl">{plan.name}</CardTitle>
<CardDescription className="space-y-3">
<div className="flex items-baseline">
<span className="text-3xl font-bold text-neutral-900 dark:text-white">
{plan.price}
</span>
{plan.interval && (
<span className="ml-1 text-neutral-500">
/{plan.interval}
</span>
)}
</div>
{isAnnual && plan.id !== "free" && (
<Badge className="bg-emerald-500 text-white border-0">
2 months free
</Badge>
)}
<CardTitle className="text-3xl">{plan.name}</CardTitle>
<CardDescription>
<p>{plan.description}</p>
</CardDescription>
</CardHeader>
<CardContent className="pt-0 flex-grow">
{plan.id === "pro" && (
<>
<CardContent className="pb-0">
{/* Billing toggle buttons */}
<div className="flex justify-center mb-6">
<div className="bg-neutral-100 dark:bg-neutral-800 p-1 rounded-lg inline-flex relative">
<button
onClick={() => 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
</button>
<div className="relative">
<button
onClick={() => 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
</button>
<Badge
className="absolute -top-4 -right-2 text-white border-0 pointer-events-none"
variant="green"
>
2 months free
</Badge>
</div>
</div>
</div>
{/* Event slider */}
<div className="mb-6">
<div className="mb-4">
<div className="flex justify-between mb-2 items-center">
<div className="text-neutral-600 dark:text-neutral-400 text-sm">
Events per month
<div className="font-bold text-lg text-white">
{eventLimit.toLocaleString()}
</div>
</div>
<div className="flex items-baseline">
<span className="text-3xl font-bold text-neutral-900 dark:text-white">
{plan.price}
</span>
{plan.interval && (
<span className="ml-1 text-neutral-500">
/{plan.interval}
</span>
)}
</div>
</div>
</div>
<Slider
defaultValue={[0]} // Default to index 0 (20k)
max={EVENT_TIERS.length - 1}
min={0}
step={1}
onValueChange={handleSliderChange}
className="mb-3"
/>
<div className="flex justify-between text-xs text-neutral-500">
{EVENT_TIERS.map((tier, index) => (
<span
key={index}
className={
eventLimitIndex === index
? "font-bold text-white"
: ""
}
>
{tier >= 1_000_000
? `${tier / 1_000_000}M`
: `${tier / 1_000}K`}
</span>
))}
</div>
</div>
</CardContent>
</>
)}
<CardContent className={"pt-0 flex-grow"}>
<div className="w-full h-px bg-neutral-200 dark:bg-neutral-800 mb-4"></div>
<ul className="space-y-3 text-sm">
{plan.features.map((feature, i) => (
@ -466,12 +414,16 @@ export default function Subscribe() {
</CardContent>
<CardFooter>
{plan.id === "basic" ? (
{plan.id === "pro" ? (
<Button
onClick={() => handleSubscribe(plan.id)}
className="w-full"
disabled={isLoading}
variant="success"
>
Subscribe to {plan.name}
{isLoading
? "Processing..."
: `Subscribe to ${plan.name}`}
</Button>
) : (
<Button

View file

@ -11,7 +11,7 @@ const alertVariants = cva(
default:
"bg-white text-neutral-950 dark:bg-neutral-950 dark:text-neutral-50",
destructive:
"border-red-500/50 text-red-500 dark:border-red-500 [&>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: {

View file

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

View file

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

View file

@ -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<number> {
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,
};
})

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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<number>();
@ -53,42 +54,64 @@ async function getSiteIdsForUser(userId: string): Promise<number[]> {
}
/**
* 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

View file

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

View file

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

View file

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

View file

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

17
server/src/lib/stripe.ts Normal file
View file

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