eny.space Landingpage
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

refactor(app): simplify subscription management by reducing stored data and enhancing integration

Sam Sauer 916940d7 a9f6daa9

+372 -269
+154 -84
app/actions/subscription.ts
··· 6 6 import { headers } from "next/headers"; 7 7 import type { Stripe } from "stripe"; 8 8 9 - export async function getSubscriptionStatus() { 9 + /** 10 + * Get user's Stripe customer ID from database (minimal storage) 11 + */ 12 + async function getStripeCustomerId(): Promise<string | null> { 10 13 const supabase = await createClient(); 11 14 const { 12 15 data: { user }, 13 16 } = await supabase.auth.getUser(); 14 17 15 18 if (!user) { 16 - return { subscribed: false, subscription: null }; 19 + return null; 17 20 } 18 21 19 22 const { data: subscription } = await supabase 20 23 .from("subscriptions") 21 - .select("*") 24 + .select("stripe_customer_id") 22 25 .eq("user_id", user.id) 23 - .in("status", ["active", "trialing"]) 24 - .order("created_at", { ascending: false }) 25 26 .limit(1) 26 27 .maybeSingle(); 27 28 28 - return { 29 - subscribed: !!subscription && (subscription.status === "active" || subscription.status === "trialing"), 30 - subscription, 31 - }; 29 + return subscription?.stripe_customer_id || null; 32 30 } 33 31 34 - export async function syncSubscriptionFromCheckoutSession(sessionId: string) { 35 - const supabase = await createClient(); 36 - const { 37 - data: { user }, 38 - } = await supabase.auth.getUser(); 39 - 40 - if (!user) { 41 - return { success: false, error: "Not authenticated" }; 32 + /** 33 + * Get active subscription directly from Stripe (source of truth) 34 + */ 35 + export async function getActiveSubscription(): Promise<Stripe.Subscription | null> { 36 + const customerId = await getStripeCustomerId(); 37 + if (!customerId) { 38 + return null; 42 39 } 43 40 44 41 try { 45 - // Retrieve the checkout session from Stripe 46 - const session = await stripe.checkout.sessions.retrieve(sessionId, { 47 - expand: ["subscription"], 42 + // Get all subscriptions for this customer 43 + const subscriptions = await stripe.subscriptions.list({ 44 + customer: customerId, 45 + status: "all", 46 + limit: 10, 48 47 }); 49 48 50 - // SECURITY: Verify the session belongs to this user 51 - if (session.metadata?.user_id !== user.id) { 52 - console.error(`Session ${sessionId} does not belong to user ${user.id}`); 53 - return { success: false, error: "Session does not belong to this user" }; 54 - } 49 + // Find active or trialing subscription 50 + const activeSubscription = subscriptions.data.find( 51 + (sub) => (sub.status === "active" || sub.status === "trialing") && !sub.cancel_at_period_end 52 + ); 55 53 56 - // SECURITY: Verify payment was successful 57 - if (session.payment_status !== "paid") { 58 - return { success: false, error: "Payment not completed" }; 59 - } 54 + return activeSubscription || null; 55 + } catch (error) { 56 + console.error("Error fetching subscription from Stripe:", error); 57 + return null; 58 + } 59 + } 60 60 61 - if (session.mode === "subscription" && session.subscription) { 62 - const subscription = typeof session.subscription === "string" 63 - ? await stripe.subscriptions.retrieve(session.subscription) 64 - : session.subscription; 61 + /** 62 + * Get subscription status for UI (always from Stripe) 63 + */ 64 + export async function getSubscriptionStatus() { 65 + const subscription = await getActiveSubscription(); 65 66 66 - // SECURITY: Verify subscription customer matches session customer 67 - if (subscription.customer !== session.customer) { 68 - return { success: false, error: "Subscription customer mismatch" }; 69 - } 67 + return { 68 + subscribed: !!subscription, 69 + subscription: subscription ? { 70 + status: subscription.status, 71 + cancel_at_period_end: subscription.cancel_at_period_end, 72 + current_period_end: new Date(subscription.current_period_end * 1000).toISOString(), 73 + current_period_start: new Date(subscription.current_period_start * 1000).toISOString(), 74 + } : null, 75 + }; 76 + } 70 77 71 - // Use admin client to bypass RLS (this is a validated update from Stripe) 72 - // If admin client not available, we can't update (security: prevents user manipulation) 73 - let supabaseClient; 74 - try { 75 - supabaseClient = createAdminClient(); 76 - } catch (error) { 77 - console.error("Admin client required for subscription sync:", error); 78 - return { 79 - success: false, 80 - error: "Server configuration error. Please contact support." 81 - }; 82 - } 83 - 84 - const { error: dbError } = await supabaseClient.from("subscriptions").upsert({ 85 - user_id: user.id, 86 - stripe_customer_id: subscription.customer as string, 87 - stripe_subscription_id: subscription.id, 88 - stripe_price_id: subscription.items.data[0]?.price.id, 89 - status: subscription.status, 90 - current_period_start: new Date(subscription.current_period_start * 1000).toISOString(), 91 - current_period_end: new Date(subscription.current_period_end * 1000).toISOString(), 92 - cancel_at_period_end: subscription.cancel_at_period_end, 93 - }); 78 + /** 79 + * Verify active subscription for protected routes (always checks Stripe) 80 + */ 81 + export async function verifyActiveSubscription(): Promise<{ active: boolean; subscription: Stripe.Subscription | null }> { 82 + const subscription = await getActiveSubscription(); 94 83 95 - if (dbError) { 96 - console.error("Database error:", dbError); 97 - return { success: false, error: "Failed to sync subscription" }; 98 - } 99 - 100 - return { success: true }; 101 - } 102 - 103 - return { success: false, error: "Not a subscription session" }; 104 - } catch (error) { 105 - console.error("Error syncing subscription:", error); 106 - return { success: false, error: error instanceof Error ? error.message : "Unknown error" }; 107 - } 84 + return { 85 + active: !!subscription, 86 + subscription, 87 + }; 108 88 } 109 89 90 + /** 91 + * Create checkout session for new subscription 92 + */ 110 93 export async function createSubscriptionCheckout(priceId: string) { 111 94 const supabase = await createClient(); 112 95 const { ··· 118 101 } 119 102 120 103 // Get or create Stripe customer 121 - let customerId: string; 122 - const { data: existingSubscription } = await supabase 123 - .from("subscriptions") 124 - .select("stripe_customer_id") 125 - .eq("user_id", user.id) 126 - .maybeSingle(); 104 + let customerId = await getStripeCustomerId(); 127 105 128 - if (existingSubscription?.stripe_customer_id) { 129 - customerId = existingSubscription.stripe_customer_id; 130 - } else { 106 + if (!customerId) { 131 107 const customer = await stripe.customers.create({ 132 108 email: user.email!, 133 109 metadata: { ··· 136 112 }); 137 113 customerId = customer.id; 138 114 139 - // Store customer ID in database (users can now insert their own records) 115 + // Store only customer ID in database (minimal) 140 116 await supabase.from("subscriptions").upsert({ 141 117 user_id: user.id, 142 118 stripe_customer_id: customerId, 143 - status: "incomplete", 144 119 }); 145 120 } 146 121 ··· 168 143 169 144 return { url: checkoutSession.url }; 170 145 } 146 + 147 + /** 148 + * Cancel subscription (sets cancel_at_period_end) 149 + */ 150 + export async function cancelSubscription() { 151 + const subscription = await getActiveSubscription(); 152 + 153 + if (!subscription) { 154 + return { success: false, error: "No active subscription found" }; 155 + } 156 + 157 + try { 158 + await stripe.subscriptions.update(subscription.id, { 159 + cancel_at_period_end: true, 160 + }); 161 + 162 + return { success: true }; 163 + } catch (error) { 164 + console.error("Error canceling subscription:", error); 165 + return { 166 + success: false, 167 + error: error instanceof Error ? error.message : "Failed to cancel subscription", 168 + }; 169 + } 170 + } 171 + 172 + /** 173 + * Resume subscription (removes cancel_at_period_end) 174 + */ 175 + export async function resumeSubscription() { 176 + const customerId = await getStripeCustomerId(); 177 + if (!customerId) { 178 + return { success: false, error: "No subscription found" }; 179 + } 180 + 181 + try { 182 + // Find subscription that's scheduled for cancellation 183 + const subscriptions = await stripe.subscriptions.list({ 184 + customer: customerId, 185 + status: "all", 186 + limit: 10, 187 + }); 188 + 189 + const cancelingSubscription = subscriptions.data.find( 190 + (sub) => sub.cancel_at_period_end === true && (sub.status === "active" || sub.status === "trialing") 191 + ); 192 + 193 + if (!cancelingSubscription) { 194 + return { success: false, error: "No subscription scheduled for cancellation found" }; 195 + } 196 + 197 + await stripe.subscriptions.update(cancelingSubscription.id, { 198 + cancel_at_period_end: false, 199 + }); 200 + 201 + return { success: true }; 202 + } catch (error) { 203 + console.error("Error resuming subscription:", error); 204 + return { 205 + success: false, 206 + error: error instanceof Error ? error.message : "Failed to resume subscription", 207 + }; 208 + } 209 + } 210 + 211 + /** 212 + * Create billing portal session 213 + */ 214 + export async function createBillingPortalSession() { 215 + const customerId = await getStripeCustomerId(); 216 + 217 + if (!customerId) { 218 + return { success: false, error: "No subscription found" }; 219 + } 220 + 221 + const headersList = await headers(); 222 + const originHeader = headersList.get("origin"); 223 + const hostHeader = headersList.get("host"); 224 + const origin = originHeader || `https://${hostHeader}` || process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000"; 225 + 226 + try { 227 + const session = await stripe.billingPortal.sessions.create({ 228 + customer: customerId, 229 + return_url: `${origin}/dashboard`, 230 + }); 231 + 232 + return { success: true, url: session.url }; 233 + } catch (error) { 234 + console.error("Error creating billing portal session:", error); 235 + return { 236 + success: false, 237 + error: error instanceof Error ? error.message : "Failed to create billing portal session", 238 + }; 239 + } 240 + }
+5 -4
app/api/server/[endpoint]/route.ts
··· 1 1 import { NextResponse } from "next/server"; 2 2 import { createClient } from "@/lib/supabase/server"; 3 - import { getSubscriptionStatus } from "@/actions/subscription"; 3 + import { verifyActiveSubscription } from "@/actions/subscription"; 4 4 5 5 export async function POST( 6 6 req: Request, ··· 18 18 ); 19 19 } 20 20 21 - const { subscribed } = await getSubscriptionStatus(); 21 + // Always verify subscription status directly from Stripe (source of truth) 22 + const { active } = await verifyActiveSubscription(); 22 23 23 - if (!subscribed) { 24 + if (!active) { 24 25 return NextResponse.json( 25 - { message: "Subscription required" }, 26 + { message: "Active subscription required" }, 26 27 { status: 403 } 27 28 ); 28 29 }
+24 -162
app/api/webhooks/route.ts
··· 16 16 ); 17 17 } catch (err) { 18 18 const errorMessage = err instanceof Error ? err.message : "Unknown error"; 19 - // On error, log and return the error message. 20 - if (!(err instanceof Error)) console.log(err); 21 - console.log(`❌ Error message: ${errorMessage}`); 19 + console.log(`❌ Webhook Error: ${errorMessage}`); 22 20 return NextResponse.json( 23 21 { message: `Webhook Error: ${errorMessage}` }, 24 22 { status: 400 }, 25 23 ); 26 24 } 27 25 28 - // Successfully constructed event. 29 - console.log("✅ Success:", event.id); 26 + console.log("✅ Webhook received:", event.type); 30 27 31 28 const supabase = createAdminClient(); 32 29 33 - const permittedEvents: string[] = [ 34 - "checkout.session.completed", 35 - "customer.subscription.created", 36 - "customer.subscription.updated", 37 - "customer.subscription.deleted", 38 - "invoice.payment_succeeded", 39 - "invoice.payment_failed", 40 - ]; 41 - 42 - if (permittedEvents.includes(event.type)) { 43 - try { 44 - switch (event.type) { 45 - case "checkout.session.completed": { 46 - const session = event.data.object as Stripe.Checkout.Session; 47 - console.log(`💰 CheckoutSession completed: ${session.id}`); 48 - 49 - if (session.mode === "subscription" && session.subscription) { 50 - const subscription = await stripe.subscriptions.retrieve( 51 - session.subscription as string, 52 - { expand: ["items.data.price.product"] } 53 - ); 54 - 55 - const userId = session.metadata?.user_id; 56 - if (userId) { 57 - const { error } = await supabase.from("subscriptions").upsert({ 58 - user_id: userId, 59 - stripe_customer_id: subscription.customer as string, 60 - stripe_subscription_id: subscription.id, 61 - stripe_price_id: subscription.items.data[0]?.price.id, 62 - status: subscription.status, 63 - current_period_start: new Date(subscription.current_period_start * 1000).toISOString(), 64 - current_period_end: new Date(subscription.current_period_end * 1000).toISOString(), 65 - cancel_at_period_end: subscription.cancel_at_period_end, 66 - }); 67 - 68 - if (error) { 69 - console.error("Error upserting subscription:", error); 70 - } else { 71 - console.log(`✅ Subscription synced for user ${userId}`); 72 - } 73 - } else { 74 - console.warn("No user_id in checkout session metadata"); 75 - } 76 - } 77 - break; 78 - } 79 - 80 - case "customer.subscription.created": 81 - case "customer.subscription.updated": { 82 - const subscription = event.data.object as Stripe.Subscription; 83 - console.log(`📦 Subscription ${event.type}: ${subscription.id}`); 84 - 85 - // Find user by customer ID 86 - const { data: existing } = await supabase 87 - .from("subscriptions") 88 - .select("user_id") 89 - .eq("stripe_customer_id", subscription.customer as string) 90 - .single(); 91 - 92 - if (existing?.user_id) { 93 - const { error } = await supabase.from("subscriptions").upsert({ 94 - user_id: existing.user_id, 95 - stripe_customer_id: subscription.customer as string, 96 - stripe_subscription_id: subscription.id, 97 - stripe_price_id: subscription.items.data[0]?.price.id, 98 - status: subscription.status, 99 - current_period_start: new Date(subscription.current_period_start * 1000).toISOString(), 100 - current_period_end: new Date(subscription.current_period_end * 1000).toISOString(), 101 - cancel_at_period_end: subscription.cancel_at_period_end, 102 - }); 103 - 104 - if (error) { 105 - console.error("Error upserting subscription:", error); 106 - } 107 - } else { 108 - // Try to find user by customer metadata 109 - const customer = await stripe.customers.retrieve(subscription.customer as string); 110 - if (customer && !customer.deleted && customer.metadata?.supabase_user_id) { 111 - const { error } = await supabase.from("subscriptions").upsert({ 112 - user_id: customer.metadata.supabase_user_id, 113 - stripe_customer_id: subscription.customer as string, 114 - stripe_subscription_id: subscription.id, 115 - stripe_price_id: subscription.items.data[0]?.price.id, 116 - status: subscription.status, 117 - current_period_start: new Date(subscription.current_period_start * 1000).toISOString(), 118 - current_period_end: new Date(subscription.current_period_end * 1000).toISOString(), 119 - cancel_at_period_end: subscription.cancel_at_period_end, 120 - }); 121 - 122 - if (error) { 123 - console.error("Error upserting subscription:", error); 124 - } 125 - } 126 - } 127 - break; 128 - } 30 + // Only handle checkout completion to store customer_id 31 + if (event.type === "checkout.session.completed") { 32 + const session = event.data.object as Stripe.Checkout.Session; 129 33 130 - case "customer.subscription.deleted": { 131 - const subscription = event.data.object as Stripe.Subscription; 132 - console.log(`🗑️ Subscription deleted: ${subscription.id}`); 34 + if (session.mode === "subscription" && session.customer) { 35 + const userId = session.metadata?.user_id; 36 + const customerId = typeof session.customer === "string" 37 + ? session.customer 38 + : session.customer.id; 133 39 134 - await supabase 135 - .from("subscriptions") 136 - .update({ status: "canceled" }) 137 - .eq("stripe_subscription_id", subscription.id); 138 - break; 139 - } 40 + if (userId && customerId) { 41 + // Only store user_id -> stripe_customer_id mapping (minimal) 42 + const { error } = await supabase.from("subscriptions").upsert({ 43 + user_id: userId, 44 + stripe_customer_id: customerId, 45 + }); 140 46 141 - case "invoice.payment_succeeded": { 142 - const invoice = event.data.object as Stripe.Invoice; 143 - console.log(`💳 Invoice payment succeeded: ${invoice.id}`); 144 - 145 - if (invoice.subscription) { 146 - const subscription = await stripe.subscriptions.retrieve( 147 - invoice.subscription as string 148 - ); 149 - 150 - const { data: existing } = await supabase 151 - .from("subscriptions") 152 - .select("user_id") 153 - .eq("stripe_subscription_id", subscription.id) 154 - .single(); 155 - 156 - if (existing?.user_id) { 157 - await supabase.from("subscriptions").upsert({ 158 - user_id: existing.user_id, 159 - stripe_customer_id: subscription.customer as string, 160 - stripe_subscription_id: subscription.id, 161 - stripe_price_id: subscription.items.data[0]?.price.id, 162 - status: subscription.status, 163 - current_period_start: new Date(subscription.current_period_start * 1000).toISOString(), 164 - current_period_end: new Date(subscription.current_period_end * 1000).toISOString(), 165 - cancel_at_period_end: subscription.cancel_at_period_end, 166 - }); 167 - } 168 - } 169 - break; 47 + if (error) { 48 + console.error("Error storing customer ID:", error); 49 + } else { 50 + console.log(`✅ Customer ID stored for user ${userId}`); 170 51 } 171 - 172 - case "invoice.payment_failed": { 173 - const invoice = event.data.object as Stripe.Invoice; 174 - console.log(`❌ Invoice payment failed: ${invoice.id}`); 175 - 176 - if (invoice.subscription) { 177 - await supabase 178 - .from("subscriptions") 179 - .update({ status: "past_due" }) 180 - .eq("stripe_subscription_id", invoice.subscription as string); 181 - } 182 - break; 183 - } 184 - 185 - default: 186 - console.log(`Unhandled event type: ${event.type}`); 187 52 } 188 - } catch (error) { 189 - console.error("Webhook handler error:", error); 190 - return NextResponse.json( 191 - { message: "Webhook handler failed" }, 192 - { status: 500 }, 193 - ); 194 53 } 195 54 } 196 - // Return a response to acknowledge receipt of the event. 55 + 56 + // All other subscription events are handled by querying Stripe directly 57 + // No need to sync subscription details to database 58 + 197 59 return NextResponse.json({ message: "Received" }, { status: 200 }); 198 60 }
+162 -6
app/dashboard/dashboard-client.tsx
··· 1 1 "use client"; 2 2 3 3 import { useState } from "react"; 4 - import { createSubscriptionCheckout } from "@/actions/subscription"; 4 + import { createSubscriptionCheckout, cancelSubscription, resumeSubscription, createBillingPortalSession } from "@/actions/subscription"; 5 5 6 6 interface DashboardClientProps { 7 7 subscribed: boolean; ··· 11 11 12 12 export default function DashboardClient({ subscribed, subscription, priceId }: DashboardClientProps) { 13 13 const [loading, setLoading] = useState(false); 14 + const [actionLoading, setActionLoading] = useState<string | null>(null); 14 15 15 16 const handleSubscribe = async () => { 16 17 if (!priceId) { ··· 51 52 } 52 53 }; 53 54 54 - if (!subscribed) { 55 + // Show subscription management 56 + const hasSubscription = !!subscription; 57 + const isCanceled = subscription?.status === "canceled" || subscription?.status === "past_due"; 58 + 59 + if (!hasSubscription) { 55 60 return ( 56 61 <div style={{ marginTop: "32px", padding: "24px", border: "1px solid #ccc", borderRadius: "8px" }}> 57 62 <h2>Subscribe to Access</h2> ··· 77 82 ); 78 83 } 79 84 85 + if (isCanceled) { 86 + return ( 87 + <div style={{ marginTop: "32px", padding: "24px", border: "1px solid #ccc", borderRadius: "8px", backgroundColor: "#f5f5f5" }}> 88 + <h2 style={{ color: "#666", marginTop: 0 }}>Subscription Canceled</h2> 89 + <p>Your subscription has been canceled. Subscribe again to regain access.</p> 90 + <button 91 + onClick={handleSubscribe} 92 + disabled={loading} 93 + style={{ 94 + marginTop: "16px", 95 + padding: "12px 24px", 96 + borderRadius: "6px", 97 + backgroundColor: "#000000", 98 + color: "#ffffff", 99 + border: "1px solid #ffffff", 100 + cursor: loading ? "not-allowed" : "pointer", 101 + fontWeight: 600, 102 + opacity: loading ? 0.6 : 1, 103 + }} 104 + > 105 + {loading ? "Loading..." : "Subscribe Again"} 106 + </button> 107 + </div> 108 + ); 109 + } 110 + 111 + const handleCancel = async () => { 112 + if (!confirm("Are you sure you want to cancel your subscription? You'll have access until the end of your billing period.")) { 113 + return; 114 + } 115 + 116 + setActionLoading("cancel"); 117 + try { 118 + const result = await cancelSubscription(); 119 + if (result.success) { 120 + alert("Subscription canceled. You'll have access until the end of your billing period."); 121 + window.location.reload(); 122 + } else { 123 + alert(`Error: ${result.error}`); 124 + } 125 + } catch (error) { 126 + console.error("Error canceling subscription:", error); 127 + alert("Failed to cancel subscription. Please try again."); 128 + } finally { 129 + setActionLoading(null); 130 + } 131 + }; 132 + 133 + const handleResume = async () => { 134 + setActionLoading("resume"); 135 + try { 136 + const result = await resumeSubscription(); 137 + if (result.success) { 138 + alert("Subscription resumed successfully!"); 139 + window.location.reload(); 140 + } else { 141 + alert(`Error: ${result.error}`); 142 + } 143 + } catch (error) { 144 + console.error("Error resuming subscription:", error); 145 + alert("Failed to resume subscription. Please try again."); 146 + } finally { 147 + setActionLoading(null); 148 + } 149 + }; 150 + 151 + const handleManageBilling = async () => { 152 + setActionLoading("billing"); 153 + try { 154 + const result = await createBillingPortalSession(); 155 + if (result.success && result.url) { 156 + window.location.href = result.url; 157 + } else { 158 + alert(`Error: ${result.error || "Failed to open billing portal"}`); 159 + setActionLoading(null); 160 + } 161 + } catch (error) { 162 + console.error("Error opening billing portal:", error); 163 + alert("Failed to open billing portal. Please try again."); 164 + setActionLoading(null); 165 + } 166 + }; 167 + 168 + const isCanceling = subscription?.cancel_at_period_end === true; 169 + const statusColor = isCanceling ? "#ff9800" : "#4caf50"; 170 + const statusBg = isCanceling ? "#fff3e0" : "#f0f9f0"; 171 + 80 172 return ( 81 173 <div style={{ marginTop: "32px" }}> 82 - <div style={{ padding: "24px", border: "1px solid #4caf50", borderRadius: "8px", backgroundColor: "#f0f9f0", marginBottom: "24px" }}> 83 - <h2 style={{ color: "#4caf50", marginTop: 0 }}>✓ Active Subscription</h2> 174 + <div style={{ padding: "24px", border: `1px solid ${statusColor}`, borderRadius: "8px", backgroundColor: statusBg, marginBottom: "24px" }}> 175 + <h2 style={{ color: statusColor, marginTop: 0 }}> 176 + {isCanceling ? "⚠️ Subscription Canceling" : "✓ Active Subscription"} 177 + </h2> 84 178 {subscription && ( 85 - <div> 179 + <div style={{ marginBottom: "16px" }}> 86 180 <p> 87 181 <strong>Status:</strong> {subscription.status} 88 182 </p> 89 183 {subscription.current_period_end && ( 90 184 <p> 91 - <strong>Renews:</strong> {new Date(subscription.current_period_end).toLocaleDateString()} 185 + <strong> 186 + {isCanceling ? "Access until:" : "Renews:"} 187 + </strong> {new Date(subscription.current_period_end).toLocaleDateString()} 188 + </p> 189 + )} 190 + {isCanceling && ( 191 + <p style={{ color: "#ff9800", fontWeight: 600 }}> 192 + Your subscription will cancel at the end of the billing period. 92 193 </p> 93 194 )} 94 195 </div> 95 196 )} 197 + 198 + <div style={{ display: "flex", flexDirection: "column", gap: "8px", marginTop: "16px" }}> 199 + <button 200 + onClick={handleManageBilling} 201 + disabled={actionLoading !== null} 202 + style={{ 203 + padding: "10px 20px", 204 + borderRadius: "6px", 205 + backgroundColor: "#000000", 206 + color: "#ffffff", 207 + border: "1px solid #ffffff", 208 + cursor: actionLoading !== null ? "not-allowed" : "pointer", 209 + fontWeight: 600, 210 + opacity: actionLoading !== null ? 0.6 : 1, 211 + }} 212 + > 213 + {actionLoading === "billing" ? "Loading..." : "Manage Payment Method"} 214 + </button> 215 + 216 + {isCanceling ? ( 217 + <button 218 + onClick={handleResume} 219 + disabled={actionLoading !== null} 220 + style={{ 221 + padding: "10px 20px", 222 + borderRadius: "6px", 223 + backgroundColor: "#4caf50", 224 + color: "#ffffff", 225 + border: "1px solid #4caf50", 226 + cursor: actionLoading !== null ? "not-allowed" : "pointer", 227 + fontWeight: 600, 228 + opacity: actionLoading !== null ? 0.6 : 1, 229 + }} 230 + > 231 + {actionLoading === "resume" ? "Loading..." : "Resume Subscription"} 232 + </button> 233 + ) : ( 234 + <button 235 + onClick={handleCancel} 236 + disabled={actionLoading !== null} 237 + style={{ 238 + padding: "10px 20px", 239 + borderRadius: "6px", 240 + backgroundColor: "transparent", 241 + color: "#ff5722", 242 + border: "1px solid #ff5722", 243 + cursor: actionLoading !== null ? "not-allowed" : "pointer", 244 + fontWeight: 600, 245 + opacity: actionLoading !== null ? 0.6 : 1, 246 + }} 247 + > 248 + {actionLoading === "cancel" ? "Loading..." : "Cancel Subscription"} 249 + </button> 250 + )} 251 + </div> 96 252 </div> 97 253 98 254 <div style={{ padding: "24px", border: "1px solid #ccc", borderRadius: "8px" }}>
+3 -13
app/dashboard/page.tsx
··· 1 1 import { redirect } from "next/navigation"; 2 2 import { createClient } from "@/lib/supabase/server"; 3 - import { getSubscriptionStatus, syncSubscriptionFromCheckoutSession } from "@/actions/subscription"; 3 + import { getSubscriptionStatus } from "@/actions/subscription"; 4 4 import DashboardClient from "./dashboard-client"; 5 5 6 - export default async function DashboardPage({ 7 - searchParams, 8 - }: { 9 - searchParams: Promise<{ session_id?: string }>; 10 - }) { 6 + export default async function DashboardPage() { 11 7 const supabase = await createClient(); 12 8 const { 13 9 data: { user }, ··· 17 13 redirect("/login"); 18 14 } 19 15 20 - const params = await searchParams; 21 - 22 - // If we have a session_id, try to sync the subscription (fallback if webhook hasn't fired) 23 - if (params.session_id) { 24 - await syncSubscriptionFromCheckoutSession(params.session_id); 25 - } 26 - 16 + // Always fetch subscription status directly from Stripe (source of truth) 27 17 const { subscribed, subscription } = await getSubscriptionStatus(); 28 18 29 19 return (
+24
supabase/migrations/005_simplify_subscriptions.sql
··· 1 + -- Simplify subscriptions table to only store minimal data 2 + -- Stripe API is the source of truth for all subscription details 3 + 4 + -- First, drop policies that depend on columns we're removing 5 + DROP POLICY IF EXISTS "Users can insert own subscriptions" ON subscriptions; 6 + 7 + -- Drop unnecessary columns (keep only user_id and stripe_customer_id) 8 + ALTER TABLE subscriptions 9 + DROP COLUMN IF EXISTS stripe_subscription_id, 10 + DROP COLUMN IF EXISTS stripe_price_id, 11 + DROP COLUMN IF EXISTS status, 12 + DROP COLUMN IF EXISTS current_period_start, 13 + DROP COLUMN IF EXISTS current_period_end, 14 + DROP COLUMN IF EXISTS cancel_at_period_end; 15 + 16 + -- Recreate a simpler insert policy (no status check needed since we don't store it) 17 + CREATE POLICY "Users can insert own subscriptions" 18 + ON subscriptions 19 + FOR INSERT 20 + WITH CHECK (auth.uid() = user_id); 21 + 22 + -- Keep only essential columns 23 + -- user_id: links to Supabase auth user 24 + -- stripe_customer_id: used to query Stripe API for subscription details