From 2decee28e299f40831bec2a62925555fbfd28e1c Mon Sep 17 00:00:00 2001 From: sirpiglr <49359077-sirpiglr@users.noreply.replit.com> Date: Fri, 12 Dec 2025 23:27:03 +0000 Subject: [PATCH] Add subscription management and pricing tiers to the platform Implement Stripe integration for subscription creation, management, and webhook handling. Update pricing page to display membership tiers and integrate checkout functionality. Replit-Commit-Author: Agent Replit-Commit-Session-Id: 9203795e-937a-4306-b81d-b4d5c78c240e Replit-Commit-Checkpoint-Type: intermediate_checkpoint Replit-Commit-Event-Id: 6d5c407a-473f-4820-a33d-abb2ae3e6b37 Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/7c94b7a0-29c7-4f2e-94ef-44b2153872b7/9203795e-937a-4306-b81d-b4d5c78c240e/MdI1YXa Replit-Helium-Checkpoint-Created: true --- api/subscriptions/create-checkout.ts | 138 ++++++++++ api/subscriptions/manage.ts | 139 ++++++++++ api/subscriptions/webhook.ts | 206 +++++++++++++++ client/pages/Pricing.tsx | 378 ++++++++++++++++++++++++++- 4 files changed, 851 insertions(+), 10 deletions(-) create mode 100644 api/subscriptions/create-checkout.ts create mode 100644 api/subscriptions/manage.ts create mode 100644 api/subscriptions/webhook.ts diff --git a/api/subscriptions/create-checkout.ts b/api/subscriptions/create-checkout.ts new file mode 100644 index 00000000..72c2b51d --- /dev/null +++ b/api/subscriptions/create-checkout.ts @@ -0,0 +1,138 @@ +import type { VercelRequest, VercelResponse } from "@vercel/node"; +import Stripe from "stripe"; +import { getAdminClient } from "../_supabase.js"; + +const stripe = new Stripe(process.env.STRIPE_SECRET_KEY || "", { + apiVersion: "2024-04-10", +}); + +const SUBSCRIPTION_TIERS = { + pro: { + name: "Pro", + priceMonthly: 900, + priceId: process.env.STRIPE_PRO_PRICE_ID || "", + }, + council: { + name: "Council", + priceMonthly: 2900, + priceId: process.env.STRIPE_COUNCIL_PRICE_ID || "", + }, +} as const; + +type TierKey = keyof typeof SUBSCRIPTION_TIERS; + +export default async function handler(req: VercelRequest, res: VercelResponse) { + if (req.method !== "POST") { + return res.status(405).json({ error: "Method not allowed" }); + } + + const admin = getAdminClient(); + + const authHeader = req.headers.authorization; + if (!authHeader) { + return res.status(401).json({ error: "Unauthorized" }); + } + + const token = authHeader.replace("Bearer ", ""); + const { + data: { user }, + error: authError, + } = await admin.auth.getUser(token); + + if (authError || !user) { + return res.status(401).json({ error: "Invalid token" }); + } + + try { + const { tier, successUrl, cancelUrl } = req.body; + + if (!tier || !["pro", "council"].includes(tier)) { + return res.status(400).json({ + error: "Invalid tier. Must be 'pro' or 'council'", + }); + } + + const tierKey = tier as TierKey; + const tierConfig = SUBSCRIPTION_TIERS[tierKey]; + + const { data: profile } = await admin + .from("user_profiles") + .select("stripe_customer_id, tier, full_name") + .eq("id", user.id) + .single(); + + if (profile?.tier === tier || profile?.tier === "council") { + return res.status(400).json({ + error: + profile?.tier === "council" + ? "You already have the highest tier" + : "You already have this subscription", + }); + } + + let customerId = profile?.stripe_customer_id; + + if (!customerId) { + const customer = await stripe.customers.create({ + email: user.email, + name: profile?.full_name || user.email, + metadata: { + userId: user.id, + }, + }); + customerId = customer.id; + + await admin + .from("user_profiles") + .update({ stripe_customer_id: customerId }) + .eq("id", user.id); + } + + const baseUrl = + process.env.VITE_APP_URL || process.env.REPLIT_DEV_DOMAIN + ? `https://${process.env.REPLIT_DEV_DOMAIN}` + : "https://aethex.dev"; + + const session = await stripe.checkout.sessions.create({ + customer: customerId, + mode: "subscription", + payment_method_types: ["card"], + line_items: [ + { + price_data: { + currency: "usd", + product_data: { + name: `AeThex ${tierConfig.name} Subscription`, + description: `Monthly ${tierConfig.name} tier subscription for AeThex`, + }, + unit_amount: tierConfig.priceMonthly, + recurring: { + interval: "month", + }, + }, + quantity: 1, + }, + ], + success_url: successUrl || `${baseUrl}/dashboard?subscription=success`, + cancel_url: cancelUrl || `${baseUrl}/pricing?subscription=cancelled`, + metadata: { + userId: user.id, + tier: tierKey, + }, + subscription_data: { + metadata: { + userId: user.id, + tier: tierKey, + }, + }, + }); + + return res.status(200).json({ + sessionId: session.id, + url: session.url, + }); + } catch (error: any) { + console.error("Checkout session error:", error); + return res.status(500).json({ error: error?.message || "Server error" }); + } +} diff --git a/api/subscriptions/manage.ts b/api/subscriptions/manage.ts new file mode 100644 index 00000000..da469869 --- /dev/null +++ b/api/subscriptions/manage.ts @@ -0,0 +1,139 @@ +import type { VercelRequest, VercelResponse } from "@vercel/node"; +import Stripe from "stripe"; +import { getAdminClient } from "../_supabase.js"; + +const stripe = new Stripe(process.env.STRIPE_SECRET_KEY || "", { + apiVersion: "2024-04-10", +}); + +export default async function handler(req: VercelRequest, res: VercelResponse) { + const admin = getAdminClient(); + + const authHeader = req.headers.authorization; + if (!authHeader) { + return res.status(401).json({ error: "Unauthorized" }); + } + + const token = authHeader.replace("Bearer ", ""); + const { + data: { user }, + error: authError, + } = await admin.auth.getUser(token); + + if (authError || !user) { + return res.status(401).json({ error: "Invalid token" }); + } + + const { data: profile } = await admin + .from("user_profiles") + .select("tier, stripe_customer_id, stripe_subscription_id") + .eq("id", user.id) + .single(); + + if (req.method === "GET") { + try { + let subscription = null; + + if (profile?.stripe_subscription_id) { + try { + const stripeSubscription = await stripe.subscriptions.retrieve( + profile.stripe_subscription_id + ); + subscription = { + id: stripeSubscription.id, + status: stripeSubscription.status, + currentPeriodEnd: stripeSubscription.current_period_end, + cancelAtPeriodEnd: stripeSubscription.cancel_at_period_end, + }; + } catch (err) { + console.warn("Failed to fetch Stripe subscription:", err); + } + } + + return res.status(200).json({ + tier: profile?.tier || "free", + subscription, + }); + } catch (error: any) { + console.error("Get subscription error:", error); + return res.status(500).json({ error: error?.message || "Server error" }); + } + } + + if (req.method === "POST") { + const { action } = req.body; + + if (action === "cancel") { + try { + if (!profile?.stripe_subscription_id) { + return res.status(400).json({ error: "No active subscription" }); + } + + const subscription = await stripe.subscriptions.update( + profile.stripe_subscription_id, + { cancel_at_period_end: true } + ); + + return res.status(200).json({ + message: "Subscription will be cancelled at period end", + cancelAt: subscription.current_period_end, + }); + } catch (error: any) { + console.error("Cancel subscription error:", error); + return res.status(500).json({ error: error?.message || "Server error" }); + } + } + + if (action === "resume") { + try { + if (!profile?.stripe_subscription_id) { + return res.status(400).json({ error: "No active subscription" }); + } + + const subscription = await stripe.subscriptions.update( + profile.stripe_subscription_id, + { cancel_at_period_end: false } + ); + + return res.status(200).json({ + message: "Subscription resumed", + status: subscription.status, + }); + } catch (error: any) { + console.error("Resume subscription error:", error); + return res.status(500).json({ error: error?.message || "Server error" }); + } + } + + if (action === "portal") { + try { + if (!profile?.stripe_customer_id) { + return res.status(400).json({ error: "No Stripe customer found" }); + } + + const baseUrl = + process.env.VITE_APP_URL || process.env.REPLIT_DEV_DOMAIN + ? `https://${process.env.REPLIT_DEV_DOMAIN}` + : "https://aethex.dev"; + + const portalSession = await stripe.billingPortal.sessions.create({ + customer: profile.stripe_customer_id, + return_url: `${baseUrl}/dashboard`, + }); + + return res.status(200).json({ + url: portalSession.url, + }); + } catch (error: any) { + console.error("Portal session error:", error); + return res.status(500).json({ error: error?.message || "Server error" }); + } + } + + return res.status(400).json({ + error: "Invalid action. Must be 'cancel', 'resume', or 'portal'", + }); + } + + return res.status(405).json({ error: "Method not allowed" }); +} diff --git a/api/subscriptions/webhook.ts b/api/subscriptions/webhook.ts new file mode 100644 index 00000000..253ce1de --- /dev/null +++ b/api/subscriptions/webhook.ts @@ -0,0 +1,206 @@ +import type { VercelRequest, VercelResponse } from "@vercel/node"; +import Stripe from "stripe"; +import { getAdminClient } from "../_supabase.js"; + +const stripe = new Stripe(process.env.STRIPE_SECRET_KEY || "", { + apiVersion: "2024-04-10", +}); + +const webhookSecret = process.env.STRIPE_SUBSCRIPTION_WEBHOOK_SECRET || ""; + +async function getUserIdBySubscription(admin: any, subscriptionId: string): Promise { + const { data } = await admin + .from("user_profiles") + .select("id") + .eq("stripe_subscription_id", subscriptionId) + .single(); + return data?.id || null; +} + +export const config = { + api: { + bodyParser: false, + }, +}; + +async function getRawBody(req: VercelRequest): Promise { + if (typeof req.body === "string") { + return req.body; + } + + const chunks: Buffer[] = []; + for await (const chunk of req as any) { + chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk); + } + return Buffer.concat(chunks).toString("utf8"); +} + +export default async function handler(req: VercelRequest, res: VercelResponse) { + if (req.method !== "POST") { + return res.status(405).json({ error: "Method not allowed" }); + } + + const admin = getAdminClient(); + const sig = req.headers["stripe-signature"] as string; + let event: Stripe.Event; + + try { + if (!sig || !webhookSecret) { + console.warn("Missing webhook signature or secret"); + return res + .status(400) + .json({ error: "Missing webhook signature or secret" }); + } + + const rawBody = await getRawBody(req); + event = stripe.webhooks.constructEvent(rawBody, sig, webhookSecret); + } catch (error: any) { + console.error("Webhook signature verification failed:", error.message); + return res + .status(400) + .json({ error: "Webhook signature verification failed" }); + } + + try { + switch (event.type) { + case "checkout.session.completed": { + const session = event.data.object as Stripe.Checkout.Session; + const { userId, tier } = session.metadata || {}; + + if (userId && tier && session.subscription) { + await admin + .from("user_profiles") + .update({ + tier: tier, + stripe_subscription_id: session.subscription as string, + }) + .eq("id", userId); + + console.log( + `[Subscription] User ${userId} upgraded to ${tier} tier` + ); + } + break; + } + + case "customer.subscription.updated": { + const subscription = event.data.object as Stripe.Subscription; + let { userId, tier } = subscription.metadata || {}; + + if (!userId) { + userId = await getUserIdBySubscription(admin, subscription.id); + } + + if (userId) { + const newStatus = subscription.status; + + if (newStatus === "active") { + await admin + .from("user_profiles") + .update({ + tier: tier || "pro", + stripe_subscription_id: subscription.id, + }) + .eq("id", userId); + + console.log( + `[Subscription] User ${userId} subscription active, tier: ${tier || "pro"}` + ); + } else if ( + newStatus === "canceled" || + newStatus === "unpaid" || + newStatus === "past_due" + ) { + await admin + .from("user_profiles") + .update({ + tier: "free", + }) + .eq("id", userId); + + console.log( + `[Subscription] User ${userId} subscription ${newStatus}, downgraded to free` + ); + } + } else { + console.warn( + `[Subscription] Could not find user for subscription ${subscription.id}` + ); + } + break; + } + + case "customer.subscription.deleted": { + const subscription = event.data.object as Stripe.Subscription; + let { userId } = subscription.metadata || {}; + + if (!userId) { + userId = await getUserIdBySubscription(admin, subscription.id); + } + + if (userId) { + await admin + .from("user_profiles") + .update({ + tier: "free", + stripe_subscription_id: null, + }) + .eq("id", userId); + + console.log( + `[Subscription] User ${userId} subscription deleted, downgraded to free` + ); + } else { + console.warn( + `[Subscription] Could not find user for deleted subscription ${subscription.id}` + ); + } + break; + } + + case "invoice.payment_succeeded": { + const invoice = event.data.object as Stripe.Invoice; + const subscriptionId = invoice.subscription as string; + + if (subscriptionId) { + const subscription = await stripe.subscriptions.retrieve(subscriptionId); + const { userId } = subscription.metadata || {}; + + if (userId) { + console.log( + `[Subscription] Payment succeeded for user ${userId}, subscription ${subscriptionId}` + ); + } + } + break; + } + + case "invoice.payment_failed": { + const invoice = event.data.object as Stripe.Invoice; + const subscriptionId = invoice.subscription as string; + + if (subscriptionId) { + const subscription = await stripe.subscriptions.retrieve(subscriptionId); + const { userId } = subscription.metadata || {}; + + if (userId) { + console.warn( + `[Subscription] Payment failed for user ${userId}, subscription ${subscriptionId}` + ); + } + } + break; + } + + default: + console.log(`[Subscription Webhook] Unhandled event type: ${event.type}`); + } + + return res.status(200).json({ received: true }); + } catch (error: any) { + console.error("Subscription webhook processing error:", error); + return res + .status(500) + .json({ error: error?.message || "Webhook processing failed" }); + } +} diff --git a/client/pages/Pricing.tsx b/client/pages/Pricing.tsx index 23542909..6fb8a2e5 100644 --- a/client/pages/Pricing.tsx +++ b/client/pages/Pricing.tsx @@ -9,9 +9,10 @@ import { CardDescription, CardHeader, CardTitle, + CardFooter, } from "@/components/ui/card"; import { aethexToast } from "@/lib/aethex-toast"; -import { Link } from "react-router-dom"; +import { Link, useSearchParams } from "react-router-dom"; import { BadgeDollarSign, Briefcase, @@ -28,7 +29,16 @@ import { Sparkles, Stars, Users, + Crown, + Zap, + Bot, + Lock, + Check, + Loader2, + ExternalLink, } from "lucide-react"; +import { useAuth } from "@/contexts/AuthContext"; +import { supabase } from "@/lib/supabase"; type ServiceBundle = { id: string; @@ -256,9 +266,183 @@ const faqs = [ }, ]; +type MembershipTier = { + id: "free" | "pro" | "council"; + name: string; + price: string; + priceNote?: string; + description: string; + features: string[]; + aiPersonas: string[]; + highlight?: boolean; + badge?: string; +}; + +const membershipTiers: MembershipTier[] = [ + { + id: "free", + name: "Free", + price: "$0", + priceNote: "forever", + description: "Essential access to the AeThex ecosystem with core AI assistants.", + features: [ + "Community feed & discussions", + "Basic profile & passport", + "Opportunity browsing", + "2 AI personas included", + ], + aiPersonas: ["Network Agent", "Ethics Sentinel"], + }, + { + id: "pro", + name: "Pro", + price: "$9", + priceNote: "/month", + description: "Unlock advanced AI personas and enhanced platform features.", + features: [ + "Everything in Free", + "Priority support", + "Advanced analytics", + "8 AI personas total", + "Badge unlocks via achievements", + ], + aiPersonas: [ + "Network Agent", + "Ethics Sentinel", + "Forge Master", + "SBS Architect", + "Curriculum Weaver", + "QuantumLeap", + "Vapor", + "Apex", + ], + highlight: true, + badge: "Most Popular", + }, + { + id: "council", + name: "Council", + price: "$29", + priceNote: "/month", + description: "Full access to all AI personas and exclusive Council benefits.", + features: [ + "Everything in Pro", + "All 10 AI personas", + "Council-only Discord channels", + "Early feature access", + "Direct team communication", + "Governance participation", + ], + aiPersonas: [ + "All Pro personas", + "Ethos Producer", + "AeThex Archivist", + ], + badge: "Elite", + }, +]; + export default function Engage() { const [isLoading, setIsLoading] = useState(true); + const [checkoutLoading, setCheckoutLoading] = useState(null); + const [userTier, setUserTier] = useState(null); const toastShownRef = useRef(false); + const [searchParams] = useSearchParams(); + const { user } = useAuth(); + + useEffect(() => { + const fetchUserTier = async () => { + if (!user) { + setUserTier(null); + return; + } + + const { data } = await supabase + .from("user_profiles") + .select("tier") + .eq("id", user.id) + .single(); + + setUserTier(data?.tier || "free"); + }; + + fetchUserTier(); + }, [user]); + + useEffect(() => { + const subscriptionStatus = searchParams.get("subscription"); + if (subscriptionStatus === "success") { + aethexToast.success({ title: "Subscription Activated", description: "Welcome to your new tier!" }); + } else if (subscriptionStatus === "cancelled") { + aethexToast.info({ title: "Checkout Cancelled", description: "No changes were made." }); + } + }, [searchParams]); + + const handleCheckout = async (tier: "pro" | "council") => { + if (!user) { + aethexToast.error({ title: "Sign In Required", description: "Please sign in to subscribe" }); + return; + } + + setCheckoutLoading(tier); + + try { + const { data: { session } } = await supabase.auth.getSession(); + + const response = await fetch("/api/subscriptions/create-checkout", { + method: "POST", + headers: { + "Content-Type": "application/json", + "Authorization": `Bearer ${session?.access_token}`, + }, + body: JSON.stringify({ + tier, + successUrl: `${window.location.origin}/pricing?subscription=success`, + cancelUrl: `${window.location.origin}/pricing?subscription=cancelled`, + }), + }); + + const data = await response.json(); + + if (!response.ok) { + throw new Error(data.error || "Failed to create checkout session"); + } + + if (data.url) { + window.location.href = data.url; + } + } catch (error: any) { + console.error("Checkout error:", error); + aethexToast.error({ title: "Checkout Error", description: error.message || "Failed to start checkout" }); + } finally { + setCheckoutLoading(null); + } + }; + + const handleManageSubscription = async () => { + if (!user) return; + + try { + const { data: { session } } = await supabase.auth.getSession(); + + const response = await fetch("/api/subscriptions/manage", { + method: "POST", + headers: { + "Content-Type": "application/json", + "Authorization": `Bearer ${session?.access_token}`, + }, + body: JSON.stringify({ action: "portal" }), + }); + + const data = await response.json(); + + if (data.url) { + window.open(data.url, "_blank"); + } + } catch (error) { + aethexToast.error({ title: "Billing Portal Error", description: "Failed to open billing portal" }); + } + }; useEffect(() => { const timer = setTimeout(() => { @@ -333,10 +517,10 @@ export default function Engage() { size="lg" className="bg-gradient-to-r from-aethex-500 to-neon-blue hover:from-aethex-600 hover:to-neon-blue/90 text-base sm:text-lg px-6 py-4 sm:px-8 sm:py-6" > - - - Explore Engagement Bundles - + + + View Membership Tiers + @@ -369,8 +553,182 @@ export default function Engage() { + {/* Membership Tiers */} +
+
+
+ + + AI-Powered Membership + +

+ Membership Tiers +

+

+ Unlock advanced AI personas and exclusive platform features. + Choose the tier that matches your creative ambitions. +

+ {userTier && userTier !== "free" && ( + + )} +
+ +
+ {membershipTiers.map((tier) => { + const isCurrentTier = userTier === tier.id; + const tierOrder = { free: 0, pro: 1, council: 2 }; + const canUpgrade = !userTier || tierOrder[tier.id] > tierOrder[userTier as keyof typeof tierOrder]; + + return ( + + {tier.badge && ( +
+
+ {tier.badge} +
+
+ )} + + +
+ {tier.id === "free" && } + {tier.id === "pro" && } + {tier.id === "council" && } + {tier.name} + {isCurrentTier && ( + + Current + + )} +
+
+ {tier.price} + {tier.priceNote && ( + {tier.priceNote} + )} +
+ {tier.description} +
+ + +
+ {tier.features.map((feature) => ( +
+ + {feature} +
+ ))} +
+ +
+
+ + AI Personas Included +
+
+ {tier.aiPersonas.map((persona) => ( + + {persona} + + ))} +
+
+
+ + + {tier.id === "free" ? ( + + ) : isCurrentTier ? ( + + ) : !canUpgrade ? ( + + ) : ( + + )} + +
+ ); + })} +
+ +
+

+ All subscriptions are billed monthly. Cancel anytime from your billing portal. +
+ Badges earned through achievements can unlock personas without a paid subscription. +

+
+
+
+ {/* Service Bundles */} -
+