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 */} -
+