From bf3836f3f88fe709ccb2749de0c3721b0b219643 Mon Sep 17 00:00:00 2001 From: Ashad Qureshi Date: Tue, 24 Jun 2025 17:05:50 +0500 Subject: [PATCH 1/2] Webhook Bug Fix --- .../app/api/trial/start/route.ts | 25 +++++++++++++++++- .../app/api/webhooks/route.ts | 26 +++++++------------ 2 files changed, 33 insertions(+), 18 deletions(-) diff --git a/auto-analyst-frontend/app/api/trial/start/route.ts b/auto-analyst-frontend/app/api/trial/start/route.ts index 54b31e4f..f52c537d 100644 --- a/auto-analyst-frontend/app/api/trial/start/route.ts +++ b/auto-analyst-frontend/app/api/trial/start/route.ts @@ -26,6 +26,14 @@ export async function POST(request: NextRequest) { } const userId = token.sub + + // DEBUG: Log the actual userId values + console.log('🔍 DEBUG - User ID values:') + console.log(' token.sub type:', typeof token.sub) + console.log(' token.sub value:', token.sub) + console.log(' String(token.sub):', String(token.sub)) + console.log(' JSON.stringify(token.sub):', JSON.stringify(token.sub)) + const body = await request.json() // Handle both setupIntentId (new flow) and legacy parameters @@ -87,7 +95,22 @@ export async function POST(request: NextRequest) { } // Store customer mapping for webhooks - await redis.set(`stripe:customer:${customerId}`, String(userId)) + // IMPORTANT: Ensure userId is treated as string to prevent precision loss for large numbers + const userIdAsString = String(userId) + console.log('🔍 DEBUG - Before Redis save:') + console.log(' userId original:', userId) + console.log(' userIdAsString:', userIdAsString) + console.log(' customerId:', customerId) + + await redis.set(`stripe:customer:${customerId}`, userIdAsString) + + // Immediately verify what was stored + const storedValue = await redis.get(`stripe:customer:${customerId}`) + console.log('🔍 DEBUG - After Redis save:') + console.log(' stored value:', storedValue) + console.log(' stored value type:', typeof storedValue) + console.log(' matches original?', storedValue === userId) + console.log(' matches string?', storedValue === userIdAsString) // NOW create the subscription (after payment method is confirmed) const trialEndTimestamp = TrialUtils.getTrialEndTimestamp() diff --git a/auto-analyst-frontend/app/api/webhooks/route.ts b/auto-analyst-frontend/app/api/webhooks/route.ts index 96a3170e..3b537f0a 100644 --- a/auto-analyst-frontend/app/api/webhooks/route.ts +++ b/auto-analyst-frontend/app/api/webhooks/route.ts @@ -1,6 +1,6 @@ import { NextRequest, NextResponse } from 'next/server' +import { headers } from 'next/headers' import Stripe from 'stripe' -import { Readable } from 'stream' import redis, { creditUtils, KEYS, profileUtils } from '@/lib/redis' import { sendSubscriptionConfirmation, sendPaymentConfirmationEmail } from '@/lib/email' import logger from '@/lib/utils/logger' @@ -18,15 +18,6 @@ const stripe = process.env.STRIPE_SECRET_KEY const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET || '' -// Helper function to read the raw request body as text -async function getRawBody(readable: Readable): Promise { - const chunks: Buffer[] = [] - for await (const chunk of readable) { - chunks.push(typeof chunk === 'string' ? Buffer.from(chunk) : chunk) - } - return Buffer.concat(chunks) -} - // Helper function to update a user's subscription information async function updateUserSubscription(userId: string, session: Stripe.Checkout.Session) { try { @@ -148,7 +139,8 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: 'Webhook secret not configured' }, { status: 500 }) } - const signature = request.headers.get('stripe-signature') + const headersList = await headers() + const signature = headersList.get('stripe-signature') if (!signature) { console.error('❌ No Stripe signature found in request headers') @@ -156,10 +148,10 @@ export async function POST(request: NextRequest) { } // Get the raw request body - let rawBody: Buffer + let body: string try { - rawBody = await getRawBody(request.body as unknown as Readable) - console.log(`📨 Webhook received: body length=${rawBody.length}, signature present=${!!signature}`) + body = await request.text() + console.log(`📨 Webhook received: body length=${body.length}, signature present=${!!signature}`) } catch (bodyError) { console.error('❌ Failed to read webhook body:', bodyError) return NextResponse.json({ error: 'Failed to read request body' }, { status: 400 }) @@ -168,13 +160,13 @@ export async function POST(request: NextRequest) { // Verify the webhook signature let event try { - event = stripe.webhooks.constructEvent(rawBody, signature, webhookSecret) + event = stripe.webhooks.constructEvent(body, signature, webhookSecret) console.log(`✅ Webhook signature verified: event=${event.type}, id=${event.id}`) } catch (err: any) { console.error(`❌ Webhook signature verification failed:`) console.error(` Error: ${err.message}`) console.error(` Signature: ${signature?.substring(0, 50)}...`) - console.error(` Body length: ${rawBody.length}`) + console.error(` Body length: ${body.length}`) console.error(` Webhook secret set: ${!!webhookSecret}`) console.error(` Webhook secret prefix: ${webhookSecret?.substring(0, 10)}...`) @@ -189,7 +181,7 @@ export async function POST(request: NextRequest) { debug: { hasSignature: !!signature, hasSecret: !!webhookSecret, - bodyLength: rawBody.length, + bodyLength: body.length, secretPrefix: webhookSecret?.substring(0, 10) } }, { status: 400 }) From aa0a1c9100f53d0e5ac3a941f3cd5957d8ee2ca1 Mon Sep 17 00:00:00 2001 From: Ashad Qureshi Date: Tue, 24 Jun 2025 18:15:03 +0500 Subject: [PATCH 2/2] fix: add legacy subscription cancellation support and fix Stripe webhook body parsing --- .../app/api/trial/cancel/route.ts | 102 ++++++++++-------- .../app/api/trial/start/route.ts | 25 +---- .../app/api/user/cancel-subscription/route.ts | 68 ++++++++---- .../app/api/webhooks/route.ts | 26 +++-- 4 files changed, 120 insertions(+), 101 deletions(-) diff --git a/auto-analyst-frontend/app/api/trial/cancel/route.ts b/auto-analyst-frontend/app/api/trial/cancel/route.ts index bb1f6656..7a28564f 100644 --- a/auto-analyst-frontend/app/api/trial/cancel/route.ts +++ b/auto-analyst-frontend/app/api/trial/cancel/route.ts @@ -34,53 +34,62 @@ export async function POST(request: NextRequest) { } const stripeSubscriptionId = subscriptionData.stripeSubscriptionId as string + const isLegacyUser = !stripeSubscriptionId || !stripeSubscriptionId.startsWith('sub_') - if (!stripeSubscriptionId) { - return NextResponse.json({ error: 'No subscription found' }, { status: 400 }) - } - - // Validate that we have a proper Subscription ID (not Payment Intent) - if (!stripeSubscriptionId.startsWith('sub_')) { - console.error(`Invalid subscription ID format for user ${userId}: ${stripeSubscriptionId}`) - return NextResponse.json({ - error: 'Invalid subscription data. Please contact support for assistance.', - code: 'INVALID_SUBSCRIPTION_FORMAT' - }, { status: 400 }) + // For legacy users, we'll skip Stripe API calls and just update Redis + if (isLegacyUser) { + console.log(`Legacy user ${userId} canceling - using Redis-only flow`) + } else { + // Validate that we have a proper Subscription ID for new users + if (!stripeSubscriptionId.startsWith('sub_')) { + console.error(`Invalid subscription ID format for user ${userId}: ${stripeSubscriptionId}`) + return NextResponse.json({ + error: 'Invalid subscription data. Please contact support for assistance.', + code: 'INVALID_SUBSCRIPTION_FORMAT' + }, { status: 400 }) + } } let stripeSubscription = null - try { - // First get the current subscription from Stripe - stripeSubscription = await stripe.subscriptions.retrieve(stripeSubscriptionId) - - if (stripeSubscription.status === 'trialing') { - // Cancel the subscription immediately for trials - await stripe.subscriptions.cancel(stripeSubscriptionId, { - prorate: false // Don't prorate since it's a trial cancellation - }) - console.log(`Canceled trial subscription ${stripeSubscriptionId} for user ${userId}`) - } else if (stripeSubscription.status === 'active') { - // For active subscriptions, cancel at period end - await stripe.subscriptions.update(stripeSubscriptionId, { - cancel_at_period_end: true - }) - console.log(`Scheduled cancellation for subscription ${stripeSubscriptionId} for user ${userId}`) - } else { - console.log(`Subscription ${stripeSubscriptionId} already in status: ${stripeSubscription.status}`) - } - } catch (stripeError: any) { - console.error('Error canceling subscription in Stripe:', stripeError) - // For trials, we still want to proceed with local cleanup even if Stripe fails - if (subscriptionData.status !== 'trialing') { - return NextResponse.json({ error: 'Failed to cancel subscription in Stripe' }, { status: 500 }) + + // Only make Stripe API calls for new users with proper subscription IDs + if (!isLegacyUser) { + try { + // First get the current subscription from Stripe + stripeSubscription = await stripe.subscriptions.retrieve(stripeSubscriptionId) + + if (stripeSubscription.status === 'trialing') { + // Cancel the subscription immediately for trials + await stripe.subscriptions.cancel(stripeSubscriptionId, { + prorate: false // Don't prorate since it's a trial cancellation + }) + console.log(`Canceled trial subscription ${stripeSubscriptionId} for user ${userId}`) + } else if (stripeSubscription.status === 'active') { + // For active subscriptions, cancel at period end + await stripe.subscriptions.update(stripeSubscriptionId, { + cancel_at_period_end: true + }) + console.log(`Scheduled cancellation for subscription ${stripeSubscriptionId} for user ${userId}`) + } else { + console.log(`Subscription ${stripeSubscriptionId} already in status: ${stripeSubscription.status}`) + } + } catch (stripeError: any) { + console.error('Error canceling subscription in Stripe:', stripeError) + // For trials, we still want to proceed with local cleanup even if Stripe fails + if (subscriptionData.status !== 'trialing') { + return NextResponse.json({ error: 'Failed to cancel subscription in Stripe' }, { status: 500 }) + } } + } else { + console.log(`Legacy user ${userId} - skipping Stripe API calls, updating Redis only`) } const now = new Date() const isTrial = subscriptionData.status === 'trialing' + const isLegacyActive = isLegacyUser && subscriptionData.status === 'active' - if (isTrial) { - // For trial cancellations: Immediate access removal + if (isTrial || isLegacyActive) { + // For trial cancellations OR legacy user cancellations: Immediate access removal await creditUtils.setZeroCredits(userId) await redis.hset(KEYS.USER_CREDITS(userId), { @@ -90,7 +99,8 @@ export async function POST(request: NextRequest) { lastUpdate: now.toISOString(), downgradedAt: now.toISOString(), canceledAt: now.toISOString(), - trialCanceled: 'true' // Mark this as a genuine trial cancellation + trialCanceled: isTrial ? 'true' : 'false', + legacyCanceled: isLegacyActive ? 'true' : 'false' }) // Update subscription to canceled status immediately @@ -100,11 +110,11 @@ export async function POST(request: NextRequest) { canceledAt: now.toISOString(), lastUpdated: now.toISOString(), subscriptionCanceled: 'true', - trialEndDate: '', - trialStartDate: '' + trialEndDate: isTrial ? '' : subscriptionData.trialEndDate || '', + trialStartDate: isTrial ? '' : subscriptionData.trialStartDate || '' }) - console.log(`Trial canceled for user ${userId}, access removed immediately`) + console.log(`${isTrial ? 'Trial' : 'Legacy subscription'} canceled for user ${userId}, access removed immediately`) } else { // For post-trial cancellations: Maintain access until period end // Don't change credits - let them keep access until billing cycle ends @@ -127,11 +137,11 @@ export async function POST(request: NextRequest) { return NextResponse.json({ success: true, - message: isTrial - ? 'Trial canceled successfully. Your subscription was canceled and access has been removed.' + message: (isTrial || isLegacyActive) + ? `${isTrial ? 'Trial' : 'Subscription'} canceled successfully. Your subscription was canceled and access has been removed.` : 'Subscription scheduled for cancellation at the end of the current billing period.', subscription: subscriptionData, - credits: isTrial ? { + credits: (isTrial || isLegacyActive) ? { total: 0, used: 0, remaining: 0 @@ -140,8 +150,8 @@ export async function POST(request: NextRequest) { used: parseInt(subscriptionData.used as string || '0'), remaining: Math.max(0, parseInt(subscriptionData.total as string || '0') - parseInt(subscriptionData.used as string || '0')) }, - stripeStatus: stripeSubscription?.status || 'unknown', - willCancelAt: !isTrial && stripeSubscription?.current_period_end + stripeStatus: stripeSubscription?.status || (isLegacyUser ? 'legacy' : 'unknown'), + willCancelAt: !(isTrial || isLegacyActive) && stripeSubscription?.current_period_end ? new Date(stripeSubscription.current_period_end * 1000).toISOString() : undefined }) diff --git a/auto-analyst-frontend/app/api/trial/start/route.ts b/auto-analyst-frontend/app/api/trial/start/route.ts index f52c537d..54b31e4f 100644 --- a/auto-analyst-frontend/app/api/trial/start/route.ts +++ b/auto-analyst-frontend/app/api/trial/start/route.ts @@ -26,14 +26,6 @@ export async function POST(request: NextRequest) { } const userId = token.sub - - // DEBUG: Log the actual userId values - console.log('🔍 DEBUG - User ID values:') - console.log(' token.sub type:', typeof token.sub) - console.log(' token.sub value:', token.sub) - console.log(' String(token.sub):', String(token.sub)) - console.log(' JSON.stringify(token.sub):', JSON.stringify(token.sub)) - const body = await request.json() // Handle both setupIntentId (new flow) and legacy parameters @@ -95,22 +87,7 @@ export async function POST(request: NextRequest) { } // Store customer mapping for webhooks - // IMPORTANT: Ensure userId is treated as string to prevent precision loss for large numbers - const userIdAsString = String(userId) - console.log('🔍 DEBUG - Before Redis save:') - console.log(' userId original:', userId) - console.log(' userIdAsString:', userIdAsString) - console.log(' customerId:', customerId) - - await redis.set(`stripe:customer:${customerId}`, userIdAsString) - - // Immediately verify what was stored - const storedValue = await redis.get(`stripe:customer:${customerId}`) - console.log('🔍 DEBUG - After Redis save:') - console.log(' stored value:', storedValue) - console.log(' stored value type:', typeof storedValue) - console.log(' matches original?', storedValue === userId) - console.log(' matches string?', storedValue === userIdAsString) + await redis.set(`stripe:customer:${customerId}`, String(userId)) // NOW create the subscription (after payment method is confirmed) const trialEndTimestamp = TrialUtils.getTrialEndTimestamp() diff --git a/auto-analyst-frontend/app/api/user/cancel-subscription/route.ts b/auto-analyst-frontend/app/api/user/cancel-subscription/route.ts index 562f54f3..af5a5ec9 100644 --- a/auto-analyst-frontend/app/api/user/cancel-subscription/route.ts +++ b/auto-analyst-frontend/app/api/user/cancel-subscription/route.ts @@ -31,50 +31,74 @@ export async function POST(request: NextRequest) { // Get current subscription data from Redis const subscriptionData = await redis.hgetall(KEYS.USER_SUBSCRIPTION(userId)) - if (!subscriptionData || !subscriptionData.stripeSubscriptionId) { + if (!subscriptionData) { return NextResponse.json({ error: 'No active subscription found' }, { status: 400 }) } const stripeSubscriptionId = subscriptionData.stripeSubscriptionId as string + const isLegacyUser = !stripeSubscriptionId || !stripeSubscriptionId.startsWith('sub_') + + // For legacy users, we'll skip Stripe API calls and just update Redis + if (isLegacyUser) { + console.log(`Legacy user ${userId} canceling - using Redis-only flow`) + } try { - // Cancel the subscription in Stripe - // Using cancel_at_period_end: true to let the user keep access until the end of their current billing period - const canceledSubscription = await stripe.subscriptions.update(stripeSubscriptionId, { - cancel_at_period_end: true, - }) + let canceledSubscription = null + + // Only make Stripe API calls for new users with proper subscription IDs + if (!isLegacyUser) { + // Cancel the subscription in Stripe + // Using cancel_at_period_end: true to let the user keep access until the end of their current billing period + canceledSubscription = await stripe.subscriptions.update(stripeSubscriptionId, { + cancel_at_period_end: true, + }) + console.log(`Scheduled Stripe cancellation for subscription ${stripeSubscriptionId} for user ${userId}`) + } else { + console.log(`Legacy user ${userId} - skipping Stripe API calls, updating Redis only`) + } - // Update the subscription data in Redis with cancellation info + // Update the subscription data in Redis with cancellation info (for both legacy and new users) const now = new Date() await redis.hset(KEYS.USER_SUBSCRIPTION(userId), { - status: 'canceling', // 'canceling' means it will end at period end + status: isLegacyUser ? 'canceled' : 'canceling', // Legacy users get immediate cancellation canceledAt: now.toISOString(), lastUpdated: now.toISOString(), - // Add a flag to indicate this is pending cancellation and should get free credits next reset pendingDowngrade: 'true', - nextPlanType: 'FREE' + nextPlanType: 'STANDARD' // Changed from FREE to STANDARD since no more free plan }) - // Get current credit data - const creditData = await redis.hgetall(KEYS.USER_CREDITS(userId)) - if (creditData && creditData.resetDate) { - // Mark the credits to be reset to 0 on next reset (since no more Free plan) + // Handle credits based on user type + if (isLegacyUser) { + // Legacy users: Set credits to 0 immediately await redis.hset(KEYS.USER_CREDITS(userId), { - nextTotalCredits: '0', // No credits after cancellation - pendingDowngrade: 'true', - lastUpdate: new Date().toISOString() + total: '0', + used: '0', + resetDate: '', + lastUpdate: now.toISOString(), + downgradedAt: now.toISOString(), + canceledAt: now.toISOString() }) + } else { + // New users: Mark for downgrade at period end + const creditData = await redis.hgetall(KEYS.USER_CREDITS(userId)) + if (creditData && creditData.resetDate) { + await redis.hset(KEYS.USER_CREDITS(userId), { + nextTotalCredits: '0', // No credits after cancellation + pendingDowngrade: 'true', + lastUpdate: now.toISOString() + }) + } } - // Send cancellation confirmation email - // This would be implemented in a real application - return NextResponse.json({ success: true, - message: 'Subscription will be canceled at the end of the current billing period', + message: isLegacyUser + ? 'Subscription canceled successfully. Your access has been removed.' + : 'Subscription will be canceled at the end of the current billing period', subscription: { ...subscriptionData, - status: 'canceling', + status: isLegacyUser ? 'canceled' : 'canceling', canceledAt: now.toISOString(), } }) diff --git a/auto-analyst-frontend/app/api/webhooks/route.ts b/auto-analyst-frontend/app/api/webhooks/route.ts index 3b537f0a..96a3170e 100644 --- a/auto-analyst-frontend/app/api/webhooks/route.ts +++ b/auto-analyst-frontend/app/api/webhooks/route.ts @@ -1,6 +1,6 @@ import { NextRequest, NextResponse } from 'next/server' -import { headers } from 'next/headers' import Stripe from 'stripe' +import { Readable } from 'stream' import redis, { creditUtils, KEYS, profileUtils } from '@/lib/redis' import { sendSubscriptionConfirmation, sendPaymentConfirmationEmail } from '@/lib/email' import logger from '@/lib/utils/logger' @@ -18,6 +18,15 @@ const stripe = process.env.STRIPE_SECRET_KEY const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET || '' +// Helper function to read the raw request body as text +async function getRawBody(readable: Readable): Promise { + const chunks: Buffer[] = [] + for await (const chunk of readable) { + chunks.push(typeof chunk === 'string' ? Buffer.from(chunk) : chunk) + } + return Buffer.concat(chunks) +} + // Helper function to update a user's subscription information async function updateUserSubscription(userId: string, session: Stripe.Checkout.Session) { try { @@ -139,8 +148,7 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: 'Webhook secret not configured' }, { status: 500 }) } - const headersList = await headers() - const signature = headersList.get('stripe-signature') + const signature = request.headers.get('stripe-signature') if (!signature) { console.error('❌ No Stripe signature found in request headers') @@ -148,10 +156,10 @@ export async function POST(request: NextRequest) { } // Get the raw request body - let body: string + let rawBody: Buffer try { - body = await request.text() - console.log(`📨 Webhook received: body length=${body.length}, signature present=${!!signature}`) + rawBody = await getRawBody(request.body as unknown as Readable) + console.log(`📨 Webhook received: body length=${rawBody.length}, signature present=${!!signature}`) } catch (bodyError) { console.error('❌ Failed to read webhook body:', bodyError) return NextResponse.json({ error: 'Failed to read request body' }, { status: 400 }) @@ -160,13 +168,13 @@ export async function POST(request: NextRequest) { // Verify the webhook signature let event try { - event = stripe.webhooks.constructEvent(body, signature, webhookSecret) + event = stripe.webhooks.constructEvent(rawBody, signature, webhookSecret) console.log(`✅ Webhook signature verified: event=${event.type}, id=${event.id}`) } catch (err: any) { console.error(`❌ Webhook signature verification failed:`) console.error(` Error: ${err.message}`) console.error(` Signature: ${signature?.substring(0, 50)}...`) - console.error(` Body length: ${body.length}`) + console.error(` Body length: ${rawBody.length}`) console.error(` Webhook secret set: ${!!webhookSecret}`) console.error(` Webhook secret prefix: ${webhookSecret?.substring(0, 10)}...`) @@ -181,7 +189,7 @@ export async function POST(request: NextRequest) { debug: { hasSignature: !!signature, hasSecret: !!webhookSecret, - bodyLength: body.length, + bodyLength: rawBody.length, secretPrefix: webhookSecret?.substring(0, 10) } }, { status: 400 })