mirror of
https://github.com/rybbit-io/rybbit.git
synced 2025-05-11 12:25:36 +02:00
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:
parent
85a82b233d
commit
c631b19dd9
28 changed files with 1068 additions and 1477 deletions
|
@ -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>;
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
73
client/src/app/settings/subscription/components/FreePlan.tsx
Normal file
73
client/src/app/settings/subscription/components/FreePlan.tsx
Normal 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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
|
|
164
client/src/app/settings/subscription/components/ProPlan.tsx
Normal file
164
client/src/app/settings/subscription/components/ProPlan.tsx
Normal 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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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" />,
|
||||
|
|
|
@ -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 };
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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: {
|
||||
|
|
|
@ -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",
|
||||
},
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
})
|
||||
|
|
95
server/src/api/stripe/createCheckoutSession.ts
Normal file
95
server/src/api/stripe/createCheckoutSession.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
}
|
61
server/src/api/stripe/createPortalSession.ts
Normal file
61
server/src/api/stripe/createPortalSession.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
}
|
115
server/src/api/stripe/getSubscription.ts
Normal file
115
server/src/api/stripe/getSubscription.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
129
server/src/api/stripe/webhook.ts
Normal file
129
server/src/api/stripe/webhook.ts
Normal 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 });
|
||||
}
|
|
@ -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),
|
||||
});
|
||||
}
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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(),
|
||||
});
|
||||
|
|
|
@ -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);
|
||||
|
||||
|
|
|
@ -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(),
|
||||
|
|
|
@ -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
17
server/src/lib/stripe.ts
Normal 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
|
||||
});
|
Loading…
Add table
Add a link
Reference in a new issue