diff --git a/app/actions/subscription.ts b/app/actions/subscription.ts index fbb2ee4..e94b14a 100644 --- a/app/actions/subscription.ts +++ b/app/actions/subscription.ts @@ -6,107 +6,90 @@ import { stripe } from "@/lib/stripe"; import { headers } from "next/headers"; import type { Stripe } from "stripe"; -export async function getSubscriptionStatus() { +/** + * Get user's Stripe customer ID from database (minimal storage) + */ +async function getStripeCustomerId(): Promise { const supabase = await createClient(); const { data: { user }, } = await supabase.auth.getUser(); if (!user) { - return { subscribed: false, subscription: null }; + return null; } const { data: subscription } = await supabase .from("subscriptions") - .select("*") + .select("stripe_customer_id") .eq("user_id", user.id) - .in("status", ["active", "trialing"]) - .order("created_at", { ascending: false }) .limit(1) .maybeSingle(); - return { - subscribed: !!subscription && (subscription.status === "active" || subscription.status === "trialing"), - subscription, - }; + return subscription?.stripe_customer_id || null; } -export async function syncSubscriptionFromCheckoutSession(sessionId: string) { - const supabase = await createClient(); - const { - data: { user }, - } = await supabase.auth.getUser(); - - if (!user) { - return { success: false, error: "Not authenticated" }; +/** + * Get active subscription directly from Stripe (source of truth) + */ +export async function getActiveSubscription(): Promise { + const customerId = await getStripeCustomerId(); + if (!customerId) { + return null; } try { - // Retrieve the checkout session from Stripe - const session = await stripe.checkout.sessions.retrieve(sessionId, { - expand: ["subscription"], + // Get all subscriptions for this customer + const subscriptions = await stripe.subscriptions.list({ + customer: customerId, + status: "all", + limit: 10, }); - // SECURITY: Verify the session belongs to this user - if (session.metadata?.user_id !== user.id) { - console.error(`Session ${sessionId} does not belong to user ${user.id}`); - return { success: false, error: "Session does not belong to this user" }; - } - - // SECURITY: Verify payment was successful - if (session.payment_status !== "paid") { - return { success: false, error: "Payment not completed" }; - } + // Find active or trialing subscription + const activeSubscription = subscriptions.data.find( + (sub) => (sub.status === "active" || sub.status === "trialing") && !sub.cancel_at_period_end + ); - if (session.mode === "subscription" && session.subscription) { - const subscription = typeof session.subscription === "string" - ? await stripe.subscriptions.retrieve(session.subscription) - : session.subscription; - - // SECURITY: Verify subscription customer matches session customer - if (subscription.customer !== session.customer) { - return { success: false, error: "Subscription customer mismatch" }; - } - - // Use admin client to bypass RLS (this is a validated update from Stripe) - // If admin client not available, we can't update (security: prevents user manipulation) - let supabaseClient; - try { - supabaseClient = createAdminClient(); - } catch (error) { - console.error("Admin client required for subscription sync:", error); - return { - success: false, - error: "Server configuration error. Please contact support." - }; - } - - const { error: dbError } = await supabaseClient.from("subscriptions").upsert({ - user_id: user.id, - stripe_customer_id: subscription.customer as string, - stripe_subscription_id: subscription.id, - stripe_price_id: subscription.items.data[0]?.price.id, - status: subscription.status, - current_period_start: new Date(subscription.current_period_start * 1000).toISOString(), - current_period_end: new Date(subscription.current_period_end * 1000).toISOString(), - cancel_at_period_end: subscription.cancel_at_period_end, - }); - - if (dbError) { - console.error("Database error:", dbError); - return { success: false, error: "Failed to sync subscription" }; - } - - return { success: true }; - } - - return { success: false, error: "Not a subscription session" }; + return activeSubscription || null; } catch (error) { - console.error("Error syncing subscription:", error); - return { success: false, error: error instanceof Error ? error.message : "Unknown error" }; + console.error("Error fetching subscription from Stripe:", error); + return null; } } +/** + * Get subscription status for UI (always from Stripe) + */ +export async function getSubscriptionStatus() { + const subscription = await getActiveSubscription(); + + return { + subscribed: !!subscription, + subscription: subscription ? { + status: subscription.status, + cancel_at_period_end: subscription.cancel_at_period_end, + current_period_end: new Date(subscription.current_period_end * 1000).toISOString(), + current_period_start: new Date(subscription.current_period_start * 1000).toISOString(), + } : null, + }; +} + +/** + * Verify active subscription for protected routes (always checks Stripe) + */ +export async function verifyActiveSubscription(): Promise<{ active: boolean; subscription: Stripe.Subscription | null }> { + const subscription = await getActiveSubscription(); + + return { + active: !!subscription, + subscription, + }; +} + +/** + * Create checkout session for new subscription + */ export async function createSubscriptionCheckout(priceId: string) { const supabase = await createClient(); const { @@ -118,16 +101,9 @@ export async function createSubscriptionCheckout(priceId: string) { } // Get or create Stripe customer - let customerId: string; - const { data: existingSubscription } = await supabase - .from("subscriptions") - .select("stripe_customer_id") - .eq("user_id", user.id) - .maybeSingle(); + let customerId = await getStripeCustomerId(); - if (existingSubscription?.stripe_customer_id) { - customerId = existingSubscription.stripe_customer_id; - } else { + if (!customerId) { const customer = await stripe.customers.create({ email: user.email!, metadata: { @@ -136,11 +112,10 @@ export async function createSubscriptionCheckout(priceId: string) { }); customerId = customer.id; - // Store customer ID in database (users can now insert their own records) + // Store only customer ID in database (minimal) await supabase.from("subscriptions").upsert({ user_id: user.id, stripe_customer_id: customerId, - status: "incomplete", }); } @@ -168,3 +143,98 @@ export async function createSubscriptionCheckout(priceId: string) { return { url: checkoutSession.url }; } + +/** + * Cancel subscription (sets cancel_at_period_end) + */ +export async function cancelSubscription() { + const subscription = await getActiveSubscription(); + + if (!subscription) { + return { success: false, error: "No active subscription found" }; + } + + try { + await stripe.subscriptions.update(subscription.id, { + cancel_at_period_end: true, + }); + + return { success: true }; + } catch (error) { + console.error("Error canceling subscription:", error); + return { + success: false, + error: error instanceof Error ? error.message : "Failed to cancel subscription", + }; + } +} + +/** + * Resume subscription (removes cancel_at_period_end) + */ +export async function resumeSubscription() { + const customerId = await getStripeCustomerId(); + if (!customerId) { + return { success: false, error: "No subscription found" }; + } + + try { + // Find subscription that's scheduled for cancellation + const subscriptions = await stripe.subscriptions.list({ + customer: customerId, + status: "all", + limit: 10, + }); + + const cancelingSubscription = subscriptions.data.find( + (sub) => sub.cancel_at_period_end === true && (sub.status === "active" || sub.status === "trialing") + ); + + if (!cancelingSubscription) { + return { success: false, error: "No subscription scheduled for cancellation found" }; + } + + await stripe.subscriptions.update(cancelingSubscription.id, { + cancel_at_period_end: false, + }); + + return { success: true }; + } catch (error) { + console.error("Error resuming subscription:", error); + return { + success: false, + error: error instanceof Error ? error.message : "Failed to resume subscription", + }; + } +} + +/** + * Create billing portal session + */ +export async function createBillingPortalSession() { + const customerId = await getStripeCustomerId(); + + if (!customerId) { + return { success: false, error: "No subscription found" }; + } + + const headersList = await headers(); + const originHeader = headersList.get("origin"); + const hostHeader = headersList.get("host"); + const origin = originHeader || `https://${hostHeader}` || process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000"; + + try { + const session = await stripe.billingPortal.sessions.create({ + customer: customerId, + return_url: `${origin}/dashboard`, + }); + + return { success: true, url: session.url }; + } catch (error) { + console.error("Error creating billing portal session:", error); + return { + success: false, + error: error instanceof Error ? error.message : "Failed to create billing portal session", + }; + } +} diff --git a/app/api/server/[endpoint]/route.ts b/app/api/server/[endpoint]/route.ts index 5a26939..92d47de 100644 --- a/app/api/server/[endpoint]/route.ts +++ b/app/api/server/[endpoint]/route.ts @@ -1,6 +1,6 @@ import { NextResponse } from "next/server"; import { createClient } from "@/lib/supabase/server"; -import { getSubscriptionStatus } from "@/actions/subscription"; +import { verifyActiveSubscription } from "@/actions/subscription"; export async function POST( req: Request, @@ -18,11 +18,12 @@ export async function POST( ); } - const { subscribed } = await getSubscriptionStatus(); + // Always verify subscription status directly from Stripe (source of truth) + const { active } = await verifyActiveSubscription(); - if (!subscribed) { + if (!active) { return NextResponse.json( - { message: "Subscription required" }, + { message: "Active subscription required" }, { status: 403 } ); } diff --git a/app/api/webhooks/route.ts b/app/api/webhooks/route.ts index 6868d87..dabd3c8 100644 --- a/app/api/webhooks/route.ts +++ b/app/api/webhooks/route.ts @@ -16,183 +16,45 @@ export async function POST(req: Request) { ); } catch (err) { const errorMessage = err instanceof Error ? err.message : "Unknown error"; - // On error, log and return the error message. - if (!(err instanceof Error)) console.log(err); - console.log(`❌ Error message: ${errorMessage}`); + console.log(`❌ Webhook Error: ${errorMessage}`); return NextResponse.json( { message: `Webhook Error: ${errorMessage}` }, { status: 400 }, ); } - // Successfully constructed event. - console.log("✅ Success:", event.id); + console.log("✅ Webhook received:", event.type); const supabase = createAdminClient(); - const permittedEvents: string[] = [ - "checkout.session.completed", - "customer.subscription.created", - "customer.subscription.updated", - "customer.subscription.deleted", - "invoice.payment_succeeded", - "invoice.payment_failed", - ]; - - if (permittedEvents.includes(event.type)) { - try { - switch (event.type) { - case "checkout.session.completed": { - const session = event.data.object as Stripe.Checkout.Session; - console.log(`💰 CheckoutSession completed: ${session.id}`); - - if (session.mode === "subscription" && session.subscription) { - const subscription = await stripe.subscriptions.retrieve( - session.subscription as string, - { expand: ["items.data.price.product"] } - ); - - const userId = session.metadata?.user_id; - if (userId) { - const { error } = await supabase.from("subscriptions").upsert({ - user_id: userId, - stripe_customer_id: subscription.customer as string, - stripe_subscription_id: subscription.id, - stripe_price_id: subscription.items.data[0]?.price.id, - status: subscription.status, - current_period_start: new Date(subscription.current_period_start * 1000).toISOString(), - current_period_end: new Date(subscription.current_period_end * 1000).toISOString(), - cancel_at_period_end: subscription.cancel_at_period_end, - }); - - if (error) { - console.error("Error upserting subscription:", error); - } else { - console.log(`✅ Subscription synced for user ${userId}`); - } - } else { - console.warn("No user_id in checkout session metadata"); - } - } - break; + // Only handle checkout completion to store customer_id + if (event.type === "checkout.session.completed") { + const session = event.data.object as Stripe.Checkout.Session; + + if (session.mode === "subscription" && session.customer) { + const userId = session.metadata?.user_id; + const customerId = typeof session.customer === "string" + ? session.customer + : session.customer.id; + + if (userId && customerId) { + // Only store user_id -> stripe_customer_id mapping (minimal) + const { error } = await supabase.from("subscriptions").upsert({ + user_id: userId, + stripe_customer_id: customerId, + }); + + if (error) { + console.error("Error storing customer ID:", error); + } else { + console.log(`✅ Customer ID stored for user ${userId}`); } - - case "customer.subscription.created": - case "customer.subscription.updated": { - const subscription = event.data.object as Stripe.Subscription; - console.log(`📦 Subscription ${event.type}: ${subscription.id}`); - - // Find user by customer ID - const { data: existing } = await supabase - .from("subscriptions") - .select("user_id") - .eq("stripe_customer_id", subscription.customer as string) - .single(); - - if (existing?.user_id) { - const { error } = await supabase.from("subscriptions").upsert({ - user_id: existing.user_id, - stripe_customer_id: subscription.customer as string, - stripe_subscription_id: subscription.id, - stripe_price_id: subscription.items.data[0]?.price.id, - status: subscription.status, - current_period_start: new Date(subscription.current_period_start * 1000).toISOString(), - current_period_end: new Date(subscription.current_period_end * 1000).toISOString(), - cancel_at_period_end: subscription.cancel_at_period_end, - }); - - if (error) { - console.error("Error upserting subscription:", error); - } - } else { - // Try to find user by customer metadata - const customer = await stripe.customers.retrieve(subscription.customer as string); - if (customer && !customer.deleted && customer.metadata?.supabase_user_id) { - const { error } = await supabase.from("subscriptions").upsert({ - user_id: customer.metadata.supabase_user_id, - stripe_customer_id: subscription.customer as string, - stripe_subscription_id: subscription.id, - stripe_price_id: subscription.items.data[0]?.price.id, - status: subscription.status, - current_period_start: new Date(subscription.current_period_start * 1000).toISOString(), - current_period_end: new Date(subscription.current_period_end * 1000).toISOString(), - cancel_at_period_end: subscription.cancel_at_period_end, - }); - - if (error) { - console.error("Error upserting subscription:", error); - } - } - } - break; - } - - case "customer.subscription.deleted": { - const subscription = event.data.object as Stripe.Subscription; - console.log(`🗑️ Subscription deleted: ${subscription.id}`); - - await supabase - .from("subscriptions") - .update({ status: "canceled" }) - .eq("stripe_subscription_id", subscription.id); - break; - } - - case "invoice.payment_succeeded": { - const invoice = event.data.object as Stripe.Invoice; - console.log(`💳 Invoice payment succeeded: ${invoice.id}`); - - if (invoice.subscription) { - const subscription = await stripe.subscriptions.retrieve( - invoice.subscription as string - ); - - const { data: existing } = await supabase - .from("subscriptions") - .select("user_id") - .eq("stripe_subscription_id", subscription.id) - .single(); - - if (existing?.user_id) { - await supabase.from("subscriptions").upsert({ - user_id: existing.user_id, - stripe_customer_id: subscription.customer as string, - stripe_subscription_id: subscription.id, - stripe_price_id: subscription.items.data[0]?.price.id, - status: subscription.status, - current_period_start: new Date(subscription.current_period_start * 1000).toISOString(), - current_period_end: new Date(subscription.current_period_end * 1000).toISOString(), - cancel_at_period_end: subscription.cancel_at_period_end, - }); - } - } - break; - } - - case "invoice.payment_failed": { - const invoice = event.data.object as Stripe.Invoice; - console.log(`❌ Invoice payment failed: ${invoice.id}`); - - if (invoice.subscription) { - await supabase - .from("subscriptions") - .update({ status: "past_due" }) - .eq("stripe_subscription_id", invoice.subscription as string); - } - break; - } - - default: - console.log(`Unhandled event type: ${event.type}`); } - } catch (error) { - console.error("Webhook handler error:", error); - return NextResponse.json( - { message: "Webhook handler failed" }, - { status: 500 }, - ); } } - // Return a response to acknowledge receipt of the event. + + // All other subscription events are handled by querying Stripe directly + // No need to sync subscription details to database + return NextResponse.json({ message: "Received" }, { status: 200 }); } diff --git a/app/dashboard/dashboard-client.tsx b/app/dashboard/dashboard-client.tsx index a966c9a..9ef06f0 100644 --- a/app/dashboard/dashboard-client.tsx +++ b/app/dashboard/dashboard-client.tsx @@ -1,7 +1,7 @@ "use client"; import { useState } from "react"; -import { createSubscriptionCheckout } from "@/actions/subscription"; +import { createSubscriptionCheckout, cancelSubscription, resumeSubscription, createBillingPortalSession } from "@/actions/subscription"; interface DashboardClientProps { subscribed: boolean; @@ -11,6 +11,7 @@ interface DashboardClientProps { export default function DashboardClient({ subscribed, subscription, priceId }: DashboardClientProps) { const [loading, setLoading] = useState(false); + const [actionLoading, setActionLoading] = useState(null); const handleSubscribe = async () => { if (!priceId) { @@ -51,7 +52,11 @@ export default function DashboardClient({ subscribed, subscription, priceId }: D } }; - if (!subscribed) { + // Show subscription management + const hasSubscription = !!subscription; + const isCanceled = subscription?.status === "canceled" || subscription?.status === "past_due"; + + if (!hasSubscription) { return (

Subscribe to Access

@@ -77,22 +82,173 @@ export default function DashboardClient({ subscribed, subscription, priceId }: D ); } + if (isCanceled) { + return ( +
+

Subscription Canceled

+

Your subscription has been canceled. Subscribe again to regain access.

+ +
+ ); + } + + const handleCancel = async () => { + if (!confirm("Are you sure you want to cancel your subscription? You'll have access until the end of your billing period.")) { + return; + } + + setActionLoading("cancel"); + try { + const result = await cancelSubscription(); + if (result.success) { + alert("Subscription canceled. You'll have access until the end of your billing period."); + window.location.reload(); + } else { + alert(`Error: ${result.error}`); + } + } catch (error) { + console.error("Error canceling subscription:", error); + alert("Failed to cancel subscription. Please try again."); + } finally { + setActionLoading(null); + } + }; + + const handleResume = async () => { + setActionLoading("resume"); + try { + const result = await resumeSubscription(); + if (result.success) { + alert("Subscription resumed successfully!"); + window.location.reload(); + } else { + alert(`Error: ${result.error}`); + } + } catch (error) { + console.error("Error resuming subscription:", error); + alert("Failed to resume subscription. Please try again."); + } finally { + setActionLoading(null); + } + }; + + const handleManageBilling = async () => { + setActionLoading("billing"); + try { + const result = await createBillingPortalSession(); + if (result.success && result.url) { + window.location.href = result.url; + } else { + alert(`Error: ${result.error || "Failed to open billing portal"}`); + setActionLoading(null); + } + } catch (error) { + console.error("Error opening billing portal:", error); + alert("Failed to open billing portal. Please try again."); + setActionLoading(null); + } + }; + + const isCanceling = subscription?.cancel_at_period_end === true; + const statusColor = isCanceling ? "#ff9800" : "#4caf50"; + const statusBg = isCanceling ? "#fff3e0" : "#f0f9f0"; + return (
-
-

✓ Active Subscription

+
+

+ {isCanceling ? "⚠️ Subscription Canceling" : "✓ Active Subscription"} +

{subscription && ( -
+

Status: {subscription.status}

{subscription.current_period_end && (

- Renews: {new Date(subscription.current_period_end).toLocaleDateString()} + + {isCanceling ? "Access until:" : "Renews:"} + {new Date(subscription.current_period_end).toLocaleDateString()} +

+ )} + {isCanceling && ( +

+ Your subscription will cancel at the end of the billing period.

)}
)} + +
+ + + {isCanceling ? ( + + ) : ( + + )} +
diff --git a/app/dashboard/page.tsx b/app/dashboard/page.tsx index efc3ae9..c8e12c5 100644 --- a/app/dashboard/page.tsx +++ b/app/dashboard/page.tsx @@ -1,13 +1,9 @@ import { redirect } from "next/navigation"; import { createClient } from "@/lib/supabase/server"; -import { getSubscriptionStatus, syncSubscriptionFromCheckoutSession } from "@/actions/subscription"; +import { getSubscriptionStatus } from "@/actions/subscription"; import DashboardClient from "./dashboard-client"; -export default async function DashboardPage({ - searchParams, -}: { - searchParams: Promise<{ session_id?: string }>; -}) { +export default async function DashboardPage() { const supabase = await createClient(); const { data: { user }, @@ -17,13 +13,7 @@ export default async function DashboardPage({ redirect("/login"); } - const params = await searchParams; - - // If we have a session_id, try to sync the subscription (fallback if webhook hasn't fired) - if (params.session_id) { - await syncSubscriptionFromCheckoutSession(params.session_id); - } - + // Always fetch subscription status directly from Stripe (source of truth) const { subscribed, subscription } = await getSubscriptionStatus(); return ( diff --git a/supabase/migrations/005_simplify_subscriptions.sql b/supabase/migrations/005_simplify_subscriptions.sql new file mode 100644 index 0000000..1f5e3c8 --- /dev/null +++ b/supabase/migrations/005_simplify_subscriptions.sql @@ -0,0 +1,24 @@ +-- Simplify subscriptions table to only store minimal data +-- Stripe API is the source of truth for all subscription details + +-- First, drop policies that depend on columns we're removing +DROP POLICY IF EXISTS "Users can insert own subscriptions" ON subscriptions; + +-- Drop unnecessary columns (keep only user_id and stripe_customer_id) +ALTER TABLE subscriptions + DROP COLUMN IF EXISTS stripe_subscription_id, + DROP COLUMN IF EXISTS stripe_price_id, + DROP COLUMN IF EXISTS status, + DROP COLUMN IF EXISTS current_period_start, + DROP COLUMN IF EXISTS current_period_end, + DROP COLUMN IF EXISTS cancel_at_period_end; + +-- Recreate a simpler insert policy (no status check needed since we don't store it) +CREATE POLICY "Users can insert own subscriptions" + ON subscriptions + FOR INSERT + WITH CHECK (auth.uid() = user_id); + +-- Keep only essential columns +-- user_id: links to Supabase auth user +-- stripe_customer_id: used to query Stripe API for subscription details