From eeefdca301763af20e336b16951ac6a3b25e08bb Mon Sep 17 00:00:00 2001 From: Ashad Qureshi Date: Sun, 22 Jun 2025 22:27:33 +0500 Subject: [PATCH 1/4] Trial Plan v1.0 --- TRIAL_SYSTEM_IMPLEMENTATION.md | 171 ++++++++++++++++ auto-analyst-frontend/.env-template | 12 +- auto-analyst-frontend/.gitignore | 4 +- auto-analyst-frontend/app/account/page.tsx | 4 +- .../app/api/checkout-sessions/route.ts | 75 ++++++- .../app/api/credits/route.ts | 4 +- .../app/api/initialize-credits/route.ts | 17 +- .../app/api/payment-intent-details/route.ts | 48 +++++ .../app/api/trial/cancel/route.ts | 123 ++++++++++++ .../app/api/trial/start/route.ts | 78 ++++++++ .../app/api/user/cancel-subscription/route.ts | 4 +- .../app/api/user/credits/route.ts | 2 +- .../app/api/user/data/route.ts | 18 +- .../app/api/user/deduct-credits/route.ts | 36 ++-- .../app/api/webhooks/route.ts | 84 ++++++++ auto-analyst-frontend/app/checkout/page.tsx | 10 +- .../app/checkout/success/page.tsx | 75 +++++-- auto-analyst-frontend/app/pricing/page.tsx | 55 ++---- .../components/CheckoutForm.tsx | 83 +++++--- auto-analyst-frontend/lib/credits-config.ts | 183 ++++++++++++++++-- .../lib/features/feature-access.ts | 4 +- auto-analyst-frontend/lib/redis.ts | 114 +++++++---- .../lib/server/redis-server.ts | 6 +- auto-analyst-frontend/package-lock.json | 13 ++ auto-analyst-frontend/package.json | 1 + auto-analyst-frontend/{ | 0 26 files changed, 1016 insertions(+), 208 deletions(-) create mode 100644 TRIAL_SYSTEM_IMPLEMENTATION.md create mode 100644 auto-analyst-frontend/app/api/payment-intent-details/route.ts create mode 100644 auto-analyst-frontend/app/api/trial/cancel/route.ts create mode 100644 auto-analyst-frontend/app/api/trial/start/route.ts create mode 100644 auto-analyst-frontend/{ diff --git a/TRIAL_SYSTEM_IMPLEMENTATION.md b/TRIAL_SYSTEM_IMPLEMENTATION.md new file mode 100644 index 00000000..662ebab6 --- /dev/null +++ b/TRIAL_SYSTEM_IMPLEMENTATION.md @@ -0,0 +1,171 @@ +# 7-Day Trial System Implementation Summary + +## 🎯 **Objective Completed** +Replaced the Free plan with a 7-day trial using Stripe's manual capture system. Users must now provide payment upfront but are only charged after 7 days if they don't cancel. + +--- + +## ✅ **What We've Implemented (Phase 1)** + +### 1. **Core System Changes** +- **Removed FREE plan entirely** from credit configuration +- **Added TRIAL plan type** with 500 credits +- **Updated all plan fallbacks** to default to Standard instead of Free +- **Implemented manual capture** using Stripe's [authorization and capture](https://docs.stripe.com/payments/place-a-hold-on-a-payment-method) system + +### 2. **Credit Management Overhaul** +- **Zero credits for non-subscribers**: Users without subscription get 0 credits +- **Immediate Standard access**: Trial users get 500 credits instantly +- **Better error handling**: Clear "upgrade required" messages when credits = 0 +- **Trial credit tracking**: Redis stores trial-specific metadata + +### 3. **New API Endpoints** +``` +POST /api/trial/start - Starts trial after payment authorization +POST /api/trial/cancel - Cancels trial and removes access immediately +POST /api/checkout-sessions - Updated to use manual capture instead of subscriptions +``` + +### 4. **Stripe Integration** +- **Manual capture setup**: 7-day authorization hold on customer's card +- **Payment intent tracking**: Store payment intent ID for trial management +- **Automatic cancellation**: Cancel payment intent if user cancels trial + +### 5. **User Experience Updates** +- **Pricing page**: Removed Free tier, added "Start 7-Day Trial" button +- **Immediate feedback**: Clear error messages when credits are insufficient +- **Trial status**: Users see Standard plan benefits immediately + +### 6. **Data Migration** +- **Downgrade script**: Sets all existing free users to 0 credits +- **Redis schema update**: Added trial-specific fields (paymentIntentId, trialEndDate, etc.) + +--- + +## 📋 **Implementation Details** + +### **Trial Flow:** +1. User clicks "Start 7-Day Trial" → Redirects to checkout +2. Stripe authorizes card (no charge) → Creates payment intent with `capture_method: 'manual'` +3. User gets immediate access to 500 Standard plan credits +4. After 7 days: Stripe automatically captures payment OR user cancels and card is not charged + +### **Credit System:** +- **Trial Users**: 500 credits, planType = 'STANDARD', status = 'trial' +- **Downgraded Users**: 0 credits, can browse but can't use features +- **Paid Users**: Normal credit allocation based on their plan + +### **Cancellation Logic:** +- **During trial**: Immediate access removal + Stripe payment intent cancellation +- **After trial converts**: Access until month end (standard cancellation flow) + +--- + +## 🚧 **Still Needed (Phase 2)** + +### 1. **Automated Payment Capture** (High Priority) +- Set up Stripe scheduled webhooks or cron job to capture payments on day 7 +- Handle failed captures (expired cards, insufficient funds) +- Convert trial status to 'active' after successful capture + +### 2. **Webhook Updates** (High Priority) +- Update webhook handlers for new payment intent events: + - `payment_intent.succeeded` (trial converted to paid) + - `payment_intent.payment_failed` (capture failed) + - `payment_intent.canceled` (trial canceled) + +### 3. **UI/UX Enhancements** (Medium Priority) +- Trial countdown display in dashboard +- "Cancel Trial" button in account settings +- Better upgrade prompts when credits = 0 +- Trial status indicators throughout the app + +### 4. **Frontend Integration** (Medium Priority) +- Update checkout success page to call `/api/trial/start` +- Add trial cancellation UI components +- Update credit balance displays for trial users + +### 5. **Remaining Free Plan References** (Low Priority) +- Clean up any remaining Free plan references in: + - Feature access control + - Email templates + - UI components + - Error messages + +--- + +## 🔧 **How to Deploy** + +### **Before Deployment:** +```bash +# 1. Run the downgrade script (ONCE) +cd Auto-Analyst-CS/auto-analyst-frontend +node scripts/run-downgrade.js + +# 2. Verify all free users have been downgraded +# Check Redis: HGETALL user:*:credits should show total: "0" +``` + +### **After Deployment:** +1. Test the trial flow with Stripe test cards +2. Verify trial users get immediate Standard access +3. Test trial cancellation flow +4. Monitor for any remaining Free plan fallbacks + +--- + +## 💰 **Business Impact** + +### **Positive Changes:** +- ✅ **Immediate revenue impact**: All users must provide payment info +- ✅ **Higher conversion**: Trial users experience full Standard features +- ✅ **Reduced free-rider problem**: No more permanent free users +- ✅ **Better user qualification**: Payment info acts as user quality filter + +### **Considerations:** +- ⚠️ **Conversion tracking needed**: Monitor trial → paid conversion rates +- ⚠️ **Support volume**: May increase due to payment authorization questions +- ⚠️ **Competitive positioning**: Ensure trial period is competitive + +--- + +## 📊 **Technical Architecture Changes** + +```mermaid +graph TD + A[User] --> B[Pricing Page] + B --> C[Start 7-Day Trial] + C --> D[Stripe Checkout - Manual Capture] + D --> E[Payment Authorized] + E --> F[Trial Started - 500 Credits] + + F --> G{User Action} + G -->|Cancels| H[Cancel Payment Intent] + G -->|No Action| I[Auto-capture on Day 7] + + H --> J[0 Credits - Upgrade Required] + I --> K[Active Standard Plan] +``` + +--- + +## ⏰ **Estimated Timeline for Phase 2** + +- **Automated capture system**: 2-3 days +- **Webhook updates**: 1-2 days +- **UI/UX enhancements**: 2-3 days +- **Testing & deployment**: 1-2 days + +**Total Phase 2**: ~6-10 days + +--- + +## 🎉 **Ready to Go Live** + +The core system is functional and ready for initial deployment. Users can: +- ✅ Start trials with payment authorization +- ✅ Get immediate Standard plan access +- ✅ Cancel trials to avoid charges +- ✅ Be blocked from features when credits = 0 + +Phase 2 will add automation and polish, but the fundamental business requirement is met! \ No newline at end of file diff --git a/auto-analyst-frontend/.env-template b/auto-analyst-frontend/.env-template index adce9d6a..8df78584 100644 --- a/auto-analyst-frontend/.env-template +++ b/auto-analyst-frontend/.env-template @@ -19,7 +19,7 @@ NEXT_PUBLIC_ANALYTICS_ADMIN_PASSWORD=... NEXT_PUBLIC_ADMIN_EMAIL=... NEXT_PUBLIC_FREE_TRIAL_LIMIT=2 -ADMIN_API_KEY=... +ADMIN_API_KEY=admin123 UPSTASH_REDIS_REST_URL=... @@ -38,12 +38,4 @@ NEXT_PUBLIC_STRIPE_PREMIUM_YEARLY_PRICE_ID=... NEXT_PUBLIC_STRIPE_BASIC_PRICE_ID=... NEXT_PUBLIC_STRIPE_STANDARD_PRICE_ID=... NEXT_PUBLIC_STRIPE_PREMIUM_PRICE_ID=... - - - - - - - - - +NODE_ENV=development diff --git a/auto-analyst-frontend/.gitignore b/auto-analyst-frontend/.gitignore index a68dd492..731b1323 100644 --- a/auto-analyst-frontend/.gitignore +++ b/auto-analyst-frontend/.gitignore @@ -50,4 +50,6 @@ terraform/.tfvars logs/ -*_code*.py \ No newline at end of file +*_code*.py + +scripts/ \ No newline at end of file diff --git a/auto-analyst-frontend/app/account/page.tsx b/auto-analyst-frontend/app/account/page.tsx index e91ce7f5..0fe5cb4a 100644 --- a/auto-analyst-frontend/app/account/page.tsx +++ b/auto-analyst-frontend/app/account/page.tsx @@ -237,7 +237,7 @@ export default function AccountPage() { parseInt(String(credits.used || '0')); const total_remaining = typeof credits.total === 'number' ? credits.total : - parseInt(String(credits.total || CreditConfig.getDefaultInitialCredits())); + parseInt(String(credits.total || 0)); // No free credits anymore // Use centralized config to check if unlimited const isUnlimited = CreditConfig.isUnlimitedTotal(total_remaining); @@ -462,7 +462,7 @@ export default function AccountPage() { // Helper to determine if current subscription can be canceled const canCancel = () => { if (!subscription) return false - return subscription.status === 'active' && subscription.plan.toLowerCase() !== 'free' + return (subscription.status === 'active' || subscription.status === 'trialing') && subscription.plan.toLowerCase() !== 'free' } if (status === 'loading' || loading) { diff --git a/auto-analyst-frontend/app/api/checkout-sessions/route.ts b/auto-analyst-frontend/app/api/checkout-sessions/route.ts index dbaa8e25..29cc1763 100644 --- a/auto-analyst-frontend/app/api/checkout-sessions/route.ts +++ b/auto-analyst-frontend/app/api/checkout-sessions/route.ts @@ -1,5 +1,6 @@ import { NextRequest, NextResponse } from 'next/server' import Stripe from 'stripe' +import { TrialUtils } from '@/lib/credits-config' export const dynamic = 'force-dynamic' @@ -86,41 +87,97 @@ export async function POST(request: NextRequest) { } } - // Prepare subscription creation parameters + // Calculate trial end date using centralized config + const trialEndTimestamp = TrialUtils.getTrialEndTimestamp() + + // Create subscription with trial period instead of PaymentIntent const subscriptionParams: Stripe.SubscriptionCreateParams = { customer: customerId, items: [{ price: priceId }], - payment_behavior: 'default_incomplete', - payment_settings: { save_default_payment_method: 'on_subscription' }, + trial_end: trialEndTimestamp, expand: ['latest_invoice.payment_intent'], + payment_behavior: 'default_incomplete', // Ensure we get a setup intent for trial + payment_settings: { + save_default_payment_method: 'on_subscription', // Save payment method for after trial + }, metadata: { userId: userId || 'anonymous', planName, interval, + priceId, + isTrial: 'true', + trialEndDate: TrialUtils.getTrialEndDate(), ...(promoCode && { promoCode }), }, } - // Apply coupon if valid promo code was found + // Apply discount if coupon is valid if (couponId) { subscriptionParams.coupon = couponId } - // Create a subscription with the provided price ID and optional coupon + // Create subscription with trial const subscription = await stripe.subscriptions.create(subscriptionParams) - // Get the client secret from the payment intent - // @ts-ignore - We know this exists because we expanded it - const clientSecret = subscription.latest_invoice.payment_intent.client_secret + if (!subscription?.latest_invoice) { + return NextResponse.json({ message: 'Failed to create trial subscription' }, { status: 500 }) + } + + // For trials, the latest_invoice should have $0 amount but still need payment method + const invoice = subscription.latest_invoice as Stripe.Invoice + const paymentIntent = invoice.payment_intent as Stripe.PaymentIntent + + // If no payment intent, create a setup intent for payment method collection + let clientSecret = paymentIntent?.client_secret + let paymentIntentId = paymentIntent?.id + + if (!clientSecret && subscription.status === 'trialing') { + // Create a setup intent to collect payment method for trial + const setupIntent = await stripe.setupIntents.create({ + customer: customerId, + usage: 'off_session', + metadata: { + subscription_id: subscription.id, + is_trial_setup: 'true', + }, + }) + clientSecret = setupIntent.client_secret + paymentIntentId = setupIntent.id + } return NextResponse.json({ - clientSecret, subscriptionId: subscription.id, + clientSecret: clientSecret, + paymentIntentId: paymentIntentId, + setupIntent: !paymentIntent ? paymentIntentId : null, discountApplied: !!couponId, + trialEnd: subscription.trial_end, + invoiceAmount: invoice.amount_due, // Should be 0 for trial + isTrialSetup: !paymentIntent, ...(couponId && { couponId }) }) } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred' return NextResponse.json({ message: `Stripe error: ${errorMessage}` }, { status: 500 }) } +} + +// Helper function to calculate discounted amount +async function calculateDiscountedAmount(amount: number, couponId: string): Promise { + if (!stripe) return amount + + try { + const coupon = await stripe.coupons.retrieve(couponId) + + if (coupon.percent_off) { + return Math.round(amount * (1 - coupon.percent_off / 100)) + } else if (coupon.amount_off) { + return Math.max(0, amount - coupon.amount_off) + } + + return amount + } catch (error) { + console.error('Error calculating discount:', error) + return amount + } } \ No newline at end of file diff --git a/auto-analyst-frontend/app/api/credits/route.ts b/auto-analyst-frontend/app/api/credits/route.ts index be8dee95..ec6cd68e 100644 --- a/auto-analyst-frontend/app/api/credits/route.ts +++ b/auto-analyst-frontend/app/api/credits/route.ts @@ -40,8 +40,8 @@ export async function POST(request: Request) { if (action === 'reset') { // Reset credits to the monthly allowance using centralized config - const defaultCredits = CreditConfig.getDefaultInitialCredits() - await creditUtils.initializeCredits(userIdentifier, defaultCredits) + const defaultCredits = 0 // No free credits anymore + await creditUtils.initializeTrialCredits(userIdentifier, 'manual-init', new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString()) return NextResponse.json({ success: true, credits: defaultCredits }) } else if (action === 'deduct') { // Deduct credits diff --git a/auto-analyst-frontend/app/api/initialize-credits/route.ts b/auto-analyst-frontend/app/api/initialize-credits/route.ts index 125e7fe9..840f3110 100644 --- a/auto-analyst-frontend/app/api/initialize-credits/route.ts +++ b/auto-analyst-frontend/app/api/initialize-credits/route.ts @@ -1,7 +1,6 @@ import { NextRequest, NextResponse } from 'next/server'; import { getToken } from 'next-auth/jwt'; import { creditUtils } from '@/lib/redis'; -import { CreditConfig } from '@/lib/credits-config'; export async function GET(request: NextRequest) { try { @@ -13,27 +12,21 @@ export async function GET(request: NextRequest) { const userId = token.sub; - // Allow custom amount via query param for testing - const searchParams = request.nextUrl.searchParams; - const amount = searchParams.get('amount') - ? parseInt(searchParams.get('amount') as string) - : CreditConfig.getDefaultInitialCredits(); + // This endpoint is for debugging/testing only + // Don't automatically initialize credits anymore since we removed free plan - // Initialize credits for the user - await creditUtils.initializeCredits(userId, amount); - - // Check if credits were properly set + // Check current credits const currentCredits = await creditUtils.getRemainingCredits(userId); return NextResponse.json({ success: true, userId, - initializedAmount: amount, + message: 'Credits not initialized - use trial signup or subscription to get credits', currentCredits, timestamp: new Date().toISOString() }); } catch (error) { - console.error('Error initializing credits:', error); + console.error('Error checking credits:', error); return NextResponse.json( { success: false, diff --git a/auto-analyst-frontend/app/api/payment-intent-details/route.ts b/auto-analyst-frontend/app/api/payment-intent-details/route.ts new file mode 100644 index 00000000..87386c70 --- /dev/null +++ b/auto-analyst-frontend/app/api/payment-intent-details/route.ts @@ -0,0 +1,48 @@ +import { NextRequest, NextResponse } from 'next/server' +import Stripe from 'stripe' + +// Initialize Stripe only if the secret key exists +const stripe = process.env.STRIPE_SECRET_KEY + ? new Stripe(process.env.STRIPE_SECRET_KEY, { + apiVersion: '2025-02-24.acacia', + }) + : null + +export async function GET(request: NextRequest) { + try { + // Check if Stripe is initialized + if (!stripe) { + return NextResponse.json({ error: 'Stripe configuration error' }, { status: 500 }) + } + + const searchParams = request.nextUrl.searchParams + const payment_intent = searchParams.get('payment_intent') + + if (!payment_intent) { + return NextResponse.json({ error: 'Payment intent ID is required' }, { status: 400 }) + } + + // Retrieve the payment intent + const paymentIntent = await stripe.paymentIntents.retrieve(payment_intent) + + if (!paymentIntent) { + return NextResponse.json({ error: 'Payment intent not found' }, { status: 404 }) + } + + // Return the payment intent details including metadata + return NextResponse.json({ + id: paymentIntent.id, + status: paymentIntent.status, + amount: paymentIntent.amount, + currency: paymentIntent.currency, + metadata: paymentIntent.metadata, + capture_method: paymentIntent.capture_method, + }) + } catch (error: any) { + console.error('Error retrieving payment intent details:', error) + return NextResponse.json( + { error: error.message || 'Failed to retrieve payment intent details' }, + { status: 500 } + ) + } +} \ No newline at end of file diff --git a/auto-analyst-frontend/app/api/trial/cancel/route.ts b/auto-analyst-frontend/app/api/trial/cancel/route.ts new file mode 100644 index 00000000..d06bb160 --- /dev/null +++ b/auto-analyst-frontend/app/api/trial/cancel/route.ts @@ -0,0 +1,123 @@ +import { NextRequest, NextResponse } from 'next/server' +import { getToken } from 'next-auth/jwt' +import redis, { creditUtils, KEYS } from '@/lib/redis' +import Stripe from 'stripe' + +export const dynamic = 'force-dynamic' + +// Initialize Stripe +const stripe = process.env.STRIPE_SECRET_KEY + ? new Stripe(process.env.STRIPE_SECRET_KEY, { + apiVersion: '2025-02-24.acacia', + }) + : null + +export async function POST(request: NextRequest) { + try { + // Get the user token + const token = await getToken({ req: request }) + if (!token?.sub) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + if (!stripe) { + return NextResponse.json({ error: 'Stripe configuration error' }, { status: 500 }) + } + + const userId = token.sub + + // Get user's current subscription data + const subscriptionData = await redis.hgetall(KEYS.USER_SUBSCRIPTION(userId)) + + if (!subscriptionData || !['trialing', 'active'].includes(subscriptionData.status as string)) { + return NextResponse.json({ error: 'No active trial or subscription found' }, { status: 400 }) + } + + const stripeSubscriptionId = subscriptionData.stripeSubscriptionId as string + + if (!stripeSubscriptionId) { + return NextResponse.json({ error: 'No subscription found for trial' }, { 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 }) + } + } + + const now = new Date() + + // Immediately set credits to 0 (lose access immediately as requested) + await creditUtils.setZeroCredits(userId) + + // Verify credits were actually set to 0 + const creditsAfterReset = await redis.hgetall(KEYS.USER_CREDITS(userId)) + console.log(`Credits after reset for user ${userId}:`, creditsAfterReset) + + // Update subscription to canceled status + const updatedSubscriptionData = { + ...subscriptionData, + status: 'canceled', + canceledAt: now.toISOString(), + lastUpdated: now.toISOString(), + subscriptionCanceled: 'true', + // Remove trial-specific fields + trialEndDate: '', + trialStartDate: '' + } + + await redis.hset(KEYS.USER_SUBSCRIPTION(userId), updatedSubscriptionData) + + // Remove any scheduled credit resets since user cancelled + await redis.hset(KEYS.USER_CREDITS(userId), { + resetDate: '', // No future resets for cancelled users + lastUpdate: now.toISOString(), + total: '0', // Explicitly ensure total is 0 + used: '0', // Reset used to 0 as well + }) + + console.log(`Trial canceled for user ${userId}, access removed immediately`) + + return NextResponse.json({ + success: true, + message: subscriptionData.status === 'trialing' + ? 'Trial canceled successfully. Your subscription was canceled and access has been removed.' + : 'Subscription scheduled for cancellation at the end of the current billing period.', + subscription: updatedSubscriptionData, + credits: { + total: 0, + used: 0, + remaining: 0 + }, + stripeStatus: stripeSubscription?.status || 'unknown' + }) + + } catch (error: any) { + console.error('Error canceling trial:', error) + return NextResponse.json({ + error: error.message || 'Failed to cancel trial' + }, { status: 500 }) + } +} \ No newline at end of file diff --git a/auto-analyst-frontend/app/api/trial/start/route.ts b/auto-analyst-frontend/app/api/trial/start/route.ts new file mode 100644 index 00000000..a85ed89a --- /dev/null +++ b/auto-analyst-frontend/app/api/trial/start/route.ts @@ -0,0 +1,78 @@ +import { NextRequest, NextResponse } from 'next/server' +import { getToken } from 'next-auth/jwt' +import redis, { creditUtils, KEYS } from '@/lib/redis' +import { CreditConfig, TrialUtils } from '@/lib/credits-config' + +export const dynamic = 'force-dynamic' + +export async function POST(request: NextRequest) { + try { + // Get the user token + const token = await getToken({ req: request }) + if (!token?.sub) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const userId = token.sub + const body = await request.json() + const { subscriptionId, planName, interval, amount } = body + + if (!subscriptionId || !planName) { + return NextResponse.json({ error: 'Subscription ID and plan name are required' }, { status: 400 }) + } + + const now = new Date() + const trialEndDate = TrialUtils.getTrialEndDate(now) + + // Calculate credit reset date - 1 month from checkout (not trial end) + const creditResetDate = new Date(now) + creditResetDate.setMonth(creditResetDate.getMonth() + 1) + + // Set up trial subscription with STANDARD plan type but trial status + const subscriptionData = { + plan: 'Standard Plan', + planType: 'STANDARD', // Immediate Standard access as requested + status: 'trialing', // Use Stripe's standard trialing status + amount: amount?.toString() || '15', + interval: interval || 'month', + purchaseDate: now.toISOString(), + trialStartDate: now.toISOString(), + trialEndDate: trialEndDate, + creditResetDate: creditResetDate.toISOString().split('T')[0], // Store reset date + lastUpdated: now.toISOString(), + stripeSubscriptionId: subscriptionId, // Store subscription ID instead of payment intent + willChargeOn: trialEndDate + } + + // Initialize trial credits (500 credits immediately) with custom reset date + await creditUtils.initializeTrialCredits(userId, subscriptionId, trialEndDate) + + // Set custom credit reset date (1 month from checkout) + await redis.hset(KEYS.USER_CREDITS(userId), { + resetDate: creditResetDate.toISOString().split('T')[0] + }) + + // Store subscription data in Redis + await redis.hset(KEYS.USER_SUBSCRIPTION(userId), subscriptionData) + + console.log(`Started trial for user ${userId} with subscription ${subscriptionId}`) + + return NextResponse.json({ + success: true, + subscription: subscriptionData, + credits: { + total: TrialUtils.getTrialCredits(), + used: 0, + remaining: TrialUtils.getTrialCredits() + }, + trialEndDate: trialEndDate, + message: 'Trial started successfully! You have immediate access to 500 credits.' + }) + + } catch (error: any) { + console.error('Error starting trial:', error) + return NextResponse.json({ + error: error.message || 'Failed to start trial' + }, { status: 500 }) + } +} \ No newline at end of file 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 43b0dfb4..562f54f3 100644 --- a/auto-analyst-frontend/app/api/user/cancel-subscription/route.ts +++ b/auto-analyst-frontend/app/api/user/cancel-subscription/route.ts @@ -58,9 +58,9 @@ export async function POST(request: NextRequest) { // Get current credit data const creditData = await redis.hgetall(KEYS.USER_CREDITS(userId)) if (creditData && creditData.resetDate) { - // Mark the credits to be downgraded on next reset - using centralized config + // Mark the credits to be reset to 0 on next reset (since no more Free plan) await redis.hset(KEYS.USER_CREDITS(userId), { - nextTotalCredits: CreditConfig.getCreditsForPlan('Free').total.toString(), // This will be used at the next reset + nextTotalCredits: '0', // No credits after cancellation pendingDowngrade: 'true', lastUpdate: new Date().toISOString() }) diff --git a/auto-analyst-frontend/app/api/user/credits/route.ts b/auto-analyst-frontend/app/api/user/credits/route.ts index 757354ea..b6874342 100644 --- a/auto-analyst-frontend/app/api/user/credits/route.ts +++ b/auto-analyst-frontend/app/api/user/credits/route.ts @@ -28,7 +28,7 @@ export async function GET(request: NextRequest) { planName = subscriptionHash?.plan || 'Free Plan' } else { // Initialize default values for new users using centralized config - creditsTotal = CreditConfig.getDefaultInitialCredits() + creditsTotal = 0 // No free credits anymore creditsUsed = 0 resetDate = CreditConfig.getNextResetDate() lastUpdate = new Date().toISOString() diff --git a/auto-analyst-frontend/app/api/user/data/route.ts b/auto-analyst-frontend/app/api/user/data/route.ts index 99376899..778f469c 100644 --- a/auto-analyst-frontend/app/api/user/data/route.ts +++ b/auto-analyst-frontend/app/api/user/data/route.ts @@ -31,25 +31,25 @@ export async function GET(request: NextRequest) { const subscriptionData = await redis.hgetall(KEYS.USER_SUBSCRIPTION(userId)) || {} // Determine plan using centralized config - let planType = subscriptionData.planType || 'FREE' + let planType = subscriptionData.planType || 'STANDARD' let planCredits = CreditConfig.getCreditsByType(planType as any) // Fallback to plan name if planType not found - if (!planCredits || planCredits.type === 'FREE' && subscriptionData.plan) { + if (!planCredits && subscriptionData.plan) { planCredits = CreditConfig.getCreditsForPlan(subscriptionData.plan as string) } + // Final fallback to ensure we always have valid plan data + if (!planCredits) { + planCredits = CreditConfig.getCreditsByType('STANDARD') + } + // Get subscription values from Redis with defaults from centralized config const amount = subscriptionData.amount ? parseFloat(subscriptionData.amount as string) : 0 const purchaseDate = subscriptionData.purchaseDate || new Date().toISOString() const interval = subscriptionData.interval || 'month' let status = subscriptionData.status || 'inactive' - // Override status for Free plans - Free plans should always be active - if (planCredits.type === 'FREE') { - status = 'active' - } - const stripeCustomerId = subscriptionData.stripeCustomerId || '' const stripeSubscriptionId = subscriptionData.stripeSubscriptionId || '' @@ -59,8 +59,8 @@ export async function GET(request: NextRequest) { // Get credit data from Redis const creditsData = await redis.hgetall(KEYS.USER_CREDITS(userId)) || {} - // Parse credits with fallback using centralized config - const creditsTotal = parseInt(creditsData.total as string || CreditConfig.getDefaultInitialCredits().toString()) + // Parse credits with fallback - no free credits anymore + const creditsTotal = parseInt(creditsData.total as string || '0') const creditsUsed = parseInt(creditsData.used as string || '0') const resetDate = creditsData.resetDate as string || CreditConfig.getNextResetDate() const lastUpdate = creditsData.lastUpdate as string || new Date().toISOString() diff --git a/auto-analyst-frontend/app/api/user/deduct-credits/route.ts b/auto-analyst-frontend/app/api/user/deduct-credits/route.ts index 568a5d77..20916e2d 100644 --- a/auto-analyst-frontend/app/api/user/deduct-credits/route.ts +++ b/auto-analyst-frontend/app/api/user/deduct-credits/route.ts @@ -19,25 +19,33 @@ export async function POST(request: NextRequest) { const creditsHash = await redis.hgetall(KEYS.USER_CREDITS(userId)) if (!creditsHash || !creditsHash.total) { - // Initialize new user with default credits using centralized config - const defaultCredits = CreditConfig.getDefaultInitialCredits() - await redis.hset(KEYS.USER_CREDITS(userId), { - total: defaultCredits.toString(), - used: credits.toString(), - resetDate: CreditConfig.getNextResetDate(), - lastUpdate: new Date().toISOString() - }) - + // No credits for users without subscription - require upgrade return NextResponse.json({ - success: true, - remaining: defaultCredits - credits, - deducted: credits - }) + success: false, + error: 'UPGRADE_REQUIRED', + message: 'Please start your trial or upgrade your plan to continue.', + remaining: 0, + needsUpgrade: true + }, { status: 402 }) // Payment Required status code } // Calculate new used amount const total = parseInt(creditsHash.total as string) const currentUsed = creditsHash.used ? parseInt(creditsHash.used as string) : 0 + const remaining = total - currentUsed + + // Check if user has enough credits + if (remaining < credits) { + return NextResponse.json({ + success: false, + error: 'INSUFFICIENT_CREDITS', + message: 'Not enough credits remaining. Please upgrade your plan.', + remaining: remaining, + required: credits, + needsUpgrade: true + }, { status: 402 }) // Payment Required status code + } + const newUsed = currentUsed + credits // Update the credits hash @@ -46,7 +54,7 @@ export async function POST(request: NextRequest) { lastUpdate: new Date().toISOString() }) - // logger.log(`Deducted ${credits} credits for user ${userId}. New total: ${total - newUsed}`) + console.log(`Deducted ${credits} credits for user ${userId}. Remaining: ${total - newUsed}`) // Return updated credit information return NextResponse.json({ diff --git a/auto-analyst-frontend/app/api/webhooks/route.ts b/auto-analyst-frontend/app/api/webhooks/route.ts index d14aa35f..e83cc429 100644 --- a/auto-analyst-frontend/app/api/webhooks/route.ts +++ b/auto-analyst-frontend/app/api/webhooks/route.ts @@ -396,6 +396,90 @@ export async function POST(request: NextRequest) { return NextResponse.json({ received: true }) } + case 'customer.subscription.trial_will_end': { + const subscription = event.data.object as Stripe.Subscription + // logger.log('Trial will end event received:', subscription.id) + + // Get the customer ID from the subscription + const customerId = subscription.customer as string + if (!customerId) { + console.error('No customer ID found in subscription') + return NextResponse.json({ error: 'No customer ID found' }, { status: 400 }) + } + + // Look up the user by customer ID + const userKey = await redis.get(`stripe:customer:${customerId}`) + if (!userKey) { + console.error(`No user found for Stripe customer ${customerId}`) + return NextResponse.json({ received: true }) + } + + const userId = userKey.toString() + + // Optional: Send reminder email about trial ending + // You can add email notification logic here + + return NextResponse.json({ received: true }) + } + + case 'invoice.payment_succeeded': { + const invoice = event.data.object as Stripe.Invoice + + // Check if this is for a subscription that just ended its trial + if (invoice.subscription && invoice.billing_reason === 'subscription_cycle') { + const subscription = await stripe.subscriptions.retrieve(invoice.subscription as string) + + // Get customer and user info + const customerId = subscription.customer as string + const userKey = await redis.get(`stripe:customer:${customerId}`) + + if (userKey) { + const userId = userKey.toString() + + // Update subscription status from trialing to active + await redis.hset(KEYS.USER_SUBSCRIPTION(userId), { + status: 'active', + lastUpdated: new Date().toISOString(), + trialEndedAt: new Date().toISOString() + }) + + // logger.log(`User ${userId} trial ended successfully, subscription is now active`) + } + } + + return NextResponse.json({ received: true }) + } + + case 'invoice.payment_failed': { + const invoice = event.data.object as Stripe.Invoice + + // Handle failed payment after trial + if (invoice.subscription && invoice.billing_reason === 'subscription_cycle') { + const subscription = await stripe.subscriptions.retrieve(invoice.subscription as string) + + // Get customer and user info + const customerId = subscription.customer as string + const userKey = await redis.get(`stripe:customer:${customerId}`) + + if (userKey) { + const userId = userKey.toString() + + // Set credits to 0 and mark subscription as past_due + await creditUtils.setZeroCredits(userId) + + await redis.hset(KEYS.USER_SUBSCRIPTION(userId), { + status: 'past_due', + lastUpdated: new Date().toISOString(), + paymentFailedAt: new Date().toISOString() + }) + + // logger.log(`User ${userId} payment failed after trial, access removed`) + } + } + + return NextResponse.json({ received: true }) + } + // Add more event types as needed default: diff --git a/auto-analyst-frontend/app/checkout/page.tsx b/auto-analyst-frontend/app/checkout/page.tsx index 02fcef55..42859920 100644 --- a/auto-analyst-frontend/app/checkout/page.tsx +++ b/auto-analyst-frontend/app/checkout/page.tsx @@ -31,6 +31,8 @@ export default function CheckoutPage() { }) const [clientSecret, setClientSecret] = useState('') + const [subscriptionId, setSubscriptionId] = useState('') + const [isTrialSetup, setIsTrialSetup] = useState(false) const [paymentLoading, setPaymentLoading] = useState(false) const [paymentError, setPaymentError] = useState('') const [promoError, setPromoError] = useState('') @@ -70,6 +72,8 @@ export default function CheckoutPage() { } } else { setClientSecret(data.clientSecret) + setSubscriptionId(data.subscriptionId) + setIsTrialSetup(data.isTrialSetup || false) setDiscountApplied(data.discountApplied || false) setPaymentError('') setPromoError('') @@ -204,7 +208,7 @@ export default function CheckoutPage() {
-
+
@@ -233,7 +237,7 @@ export default function CheckoutPage() {
)} -
+
{clientSecret && ( )} diff --git a/auto-analyst-frontend/app/checkout/success/page.tsx b/auto-analyst-frontend/app/checkout/success/page.tsx index c7024ff5..9634f0fd 100644 --- a/auto-analyst-frontend/app/checkout/success/page.tsx +++ b/auto-analyst-frontend/app/checkout/success/page.tsx @@ -22,43 +22,80 @@ export default function CheckoutSuccess() { useEffect(() => { if (!session) return; - // Extract payment_intent from URL + // Extract subscription ID from URL (new approach) or payment_intent (legacy) + const subscription_id = searchParams?.get('subscription_id'); const payment_intent = searchParams?.get('payment_intent'); - if (payment_intent) { + if (subscription_id || payment_intent) { const processPayment = async () => { try { - // logger.log(`Processing payment intent: ${payment_intent}`) + let response, data; - // Send payment intent to our verification API - const response = await fetch('/api/verify-payment', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - payment_intent, - // Include timestamp to prevent caching issues - timestamp: new Date().getTime() - }), - }); + if (subscription_id) { + // New subscription-based trial approach + response = await fetch('/api/trial/start', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + subscriptionId: subscription_id, + planName: 'Standard', // Default to Standard for trials + interval: 'month', + amount: 15, + timestamp: new Date().getTime() + }), + }); + } else if (payment_intent) { + // Legacy payment intent approach - check if it's a trial + const stripeResponse = await fetch(`/api/payment-intent-details?payment_intent=${payment_intent}`); + const stripeData = await stripeResponse.json(); + + if (stripeData.metadata?.isTrial === 'true') { + // This is a trial payment - use the trial start endpoint + response = await fetch('/api/trial/start', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + subscriptionId: payment_intent, // Legacy compatibility + planName: stripeData.metadata?.planName || 'Standard', + interval: stripeData.metadata?.interval || 'month', + amount: stripeData.amount ? stripeData.amount / 100 : 15, // Convert from cents + timestamp: new Date().getTime() + }), + }); + } else { + // This is a regular payment - use the verify payment endpoint + response = await fetch('/api/verify-payment', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + payment_intent, + timestamp: new Date().getTime() + }), + }); + } + } - const data = await response.json(); + if (!response) { + throw new Error('No valid payment method found'); + } + + data = await response.json(); if (!response.ok) { setDebugInfo(data); throw new Error(data.error || 'Payment verification failed'); } - // logger.log('Payment verification successful:', data); - // Check if it was already processed if (data.alreadyProcessed) { logger.log('Payment was already processed'); } // Success! Show toast and redirect + const isTrialStart = subscription_id || data.subscription?.status === 'trialing'; toast({ - title: 'Subscription Activated!', - description: 'Your plan has been successfully activated.', + title: isTrialStart ? 'Trial Started!' : 'Subscription Activated!', + description: isTrialStart ? 'Your 7-day trial has started with full access.' : 'Your plan has been successfully activated.', duration: 4000 }); @@ -77,8 +114,6 @@ export default function CheckoutSuccess() { // If we haven't tried too many times, retry if (retryCount < 3) { setRetryCount(prev => prev + 1); - // logger.log(`Retrying payment verification (${retryCount + 1}/3)...`); - // Wait 2 seconds before retrying setTimeout(() => { processPayment(); diff --git a/auto-analyst-frontend/app/pricing/page.tsx b/auto-analyst-frontend/app/pricing/page.tsx index 7ae761cd..ece63920 100644 --- a/auto-analyst-frontend/app/pricing/page.tsx +++ b/auto-analyst-frontend/app/pricing/page.tsx @@ -9,31 +9,10 @@ import Link from 'next/link'; import { Infinity as InfinityIcon } from 'lucide-react'; import { MODEL_TIERS } from '@/lib/model-tiers'; import { useRouter, useSearchParams } from 'next/navigation'; +import { TrialUtils } from '@/lib/credits-config'; // Define pricing tiers with both monthly and yearly options const pricingTiers = [ - { - name: 'Free', - monthly: { - price: 0, - priceId: null, // No price ID for free tier - }, - yearly: { - price: 0, - priceId: null, // No price ID for free tier - }, - credits: { - monthly: 50, - yearly: 50, - }, - features: [ - 'Basic data analysis', - 'Standard models only', - 'Community support', - 'Limited credit usage', - ], - highlight: false, - }, { name: 'Standard', monthly: { @@ -53,8 +32,10 @@ const pricingTiers = [ 'Advanced data analysis', 'Access to all models', 'Priority support', + `${TrialUtils.getTrialDisplayText()} free trial`, ], highlight: true, + trial: true, }, { name: 'Enterprise', @@ -77,7 +58,6 @@ const pricingTiers = [ 'Priority processing', 'Custom integrations', 'Tailored solutions', - 'Custom Agents', 'Marketing Analytics APIs', ], highlight: false, @@ -151,11 +131,6 @@ export default function PricingPage() { }; const handleSubscribe = (plan: string, cycle: string) => { - if (plan === 'free') { - router.push('/chat'); - return; - } - if (plan === 'enterprise') { router.push('/contact?subject=Enterprise%20Plan%20Inquiry'); return; @@ -168,6 +143,7 @@ export default function PricingPage() { return; } + // All plans now require checkout (trial for Standard) router.push(`/checkout?plan=${plan}&cycle=${cycle}`); }; @@ -237,7 +213,7 @@ export default function PricingPage() {
{/* Pricing cards */} -
+
{pricingTiers.map((tier) => ( - {tier.name === 'Free' - ? 'Get Started' + {tier.trial + ? `Start ${TrialUtils.getTrialDisplayText()}` : tier.name === 'Enterprise' ? 'Contact Sales' : 'Subscribe'} @@ -349,7 +325,6 @@ export default function PricingPage() { Data Analysis Advanced Advanced - Advanced Custom API Access @@ -359,18 +334,12 @@ export default function PricingPage() { - - - Custom Integrations - - - @@ -380,16 +349,12 @@ export default function PricingPage() { - - - Support Level - Community Priority Dedicated @@ -502,7 +467,11 @@ export default function PricingPage() {

How are credits reset?

-

Credits are reset each month from the purchase date.

+

Credits are reset each month from your individual checkout date, not on the first of the month.

+
+
+

What happens during the {TrialUtils.getDurationDescription()} trial?

+

You get immediate access to {TrialUtils.getTrialCredits()} credits and all Standard plan features. Your card is authorized but not charged until day {TrialUtils.getTrialConfig().duration}. You can cancel anytime during the trial without being charged.

Can I upgrade or downgrade my plan?

diff --git a/auto-analyst-frontend/components/CheckoutForm.tsx b/auto-analyst-frontend/components/CheckoutForm.tsx index e060b2b8..38090f29 100644 --- a/auto-analyst-frontend/components/CheckoutForm.tsx +++ b/auto-analyst-frontend/components/CheckoutForm.tsx @@ -17,9 +17,11 @@ interface CheckoutFormProps { amount: number interval: 'month' | 'year' | 'day' clientSecret: string + isTrialSetup?: boolean + subscriptionId?: string } -export default function CheckoutForm({ planName, amount, interval, clientSecret }: CheckoutFormProps) { +export default function CheckoutForm({ planName, amount, interval, clientSecret, isTrialSetup, subscriptionId }: CheckoutFormProps) { const router = useRouter() const { data: session } = useSession() const stripe = useStripe() @@ -39,33 +41,63 @@ export default function CheckoutForm({ planName, amount, interval, clientSecret setProcessing(true) - // Use the PaymentElement instead of CardElement - const { error: submitError, paymentIntent } = await stripe.confirmPayment({ - elements, - confirmParams: { - return_url: `${window.location.origin}/checkout/success`, - }, - redirect: 'if_required', - }) + // Check if this is a SetupIntent (for trials) by looking at the client secret + const isSetupIntent = clientSecret?.startsWith('seti_') || isTrialSetup + + if (isSetupIntent) { + // For trial subscriptions, use confirmSetup to collect payment method + const { error: submitError, setupIntent } = await stripe.confirmSetup({ + elements, + confirmParams: { + return_url: `${window.location.origin}/checkout/success`, + }, + redirect: 'if_required', + }) + + setProcessing(false) - setProcessing(false) + if (submitError) { + setError(submitError.message || 'An error occurred when setting up your payment method') + } else if (setupIntent && setupIntent.status === 'succeeded') { + setError(null) + setSucceeded(true) + + // Show success animation before redirecting + setTimeout(() => { + router.push(`/checkout/success?subscription_id=${subscriptionId}`) + }, 1500) + } + } else { + // For regular payments, use confirmPayment + const { error: submitError, paymentIntent } = await stripe.confirmPayment({ + elements, + confirmParams: { + return_url: `${window.location.origin}/checkout/success`, + }, + redirect: 'if_required', + }) - if (submitError) { - setError(submitError.message || 'An error occurred when processing your payment') - } else if (paymentIntent && paymentIntent.status === 'succeeded') { - setError(null) - setSucceeded(true) - - // Show success animation for a second before redirecting - setTimeout(() => { - router.push(`/checkout/success?payment_intent=${paymentIntent.id}`) - }, 1500) + setProcessing(false) + + if (submitError) { + setError(submitError.message || 'An error occurred when processing your payment') + } else if (paymentIntent && (paymentIntent.status === 'succeeded' || paymentIntent.status === 'requires_capture')) { + // For manual capture (trial), status will be 'requires_capture' + // For normal payments, status will be 'succeeded' + setError(null) + setSucceeded(true) + + // Show success animation for a second before redirecting + setTimeout(() => { + router.push(`/checkout/success?payment_intent=${paymentIntent.id}`) + }, 1500) + } } } return (
-
+

{planName} Plan

@@ -81,7 +113,14 @@ export default function CheckoutForm({ planName, amount, interval, clientSecret Payment Details
- +
diff --git a/auto-analyst-frontend/lib/credits-config.ts b/auto-analyst-frontend/lib/credits-config.ts index a564183d..6d4afb55 100644 --- a/auto-analyst-frontend/lib/credits-config.ts +++ b/auto-analyst-frontend/lib/credits-config.ts @@ -5,8 +5,8 @@ * Update credit values here to apply changes across the entire application. */ -export type PlanType = 'FREE' | 'STANDARD' | 'PRO' -export type PlanName = 'Free' | 'Standard' | 'Pro' +export type PlanType = 'TRIAL' | 'STANDARD' | 'PRO' +export type PlanName = 'Trial' | 'Standard' | 'Pro' export interface PlanCredits { /** Total credits allocated per billing period */ @@ -24,20 +24,142 @@ export interface PlanCredits { export interface CreditThresholds { /** Threshold for considering a plan unlimited (for UI display) */ unlimitedThreshold: number - /** Default credits for new users */ - defaultInitial: number + /** Default credits for trial users */ + defaultTrial: number /** Warning threshold percentage (when to warn users about low credits) */ warningThreshold: number } +/** + * Trial Configuration + * Centralized place to manage trial duration and messaging + */ +export interface TrialConfig { + /** Trial duration amount */ + duration: number + /** Unit of time for trial ('days' | 'hours' | 'minutes' | 'weeks') */ + unit: 'days' | 'hours' | 'minutes' | 'weeks' + /** Display string for the trial period */ + displayText: string + /** Credits given during trial */ + credits: number +} + +/** + * Trial period configuration - Change here to update across the entire app + */ +export const TRIAL_CONFIG: TrialConfig = { + duration: 5, + unit: 'minutes', + displayText: '5-Minute Trial', + credits: 500 +} + +/** + * Utility class for trial management + */ +export class TrialUtils { + /** + * Get trial duration in milliseconds + */ + static getTrialDurationMs(): number { + const { duration, unit } = TRIAL_CONFIG + + switch (unit) { + case 'minutes': + return duration * 60 * 1000 + case 'hours': + return duration * 60 * 60 * 1000 + case 'days': + return duration * 24 * 60 * 60 * 1000 + case 'weeks': + return duration * 7 * 24 * 60 * 60 * 1000 + default: + return duration * 24 * 60 * 60 * 1000 // Default to days + } + } + + /** + * Get trial duration in seconds (for Stripe) + */ + static getTrialDurationSeconds(): number { + return Math.floor(this.getTrialDurationMs() / 1000) + } + + /** + * Get trial end timestamp (Unix timestamp for Stripe) + */ + static getTrialEndTimestamp(startDate?: Date): number { + const start = startDate || new Date() + return Math.floor((start.getTime() + this.getTrialDurationMs()) / 1000) + } + + /** + * Get trial end date string (ISO format) + */ + static getTrialEndDate(startDate?: Date): string { + const start = startDate || new Date() + const endDate = new Date(start.getTime() + this.getTrialDurationMs()) + return endDate.toISOString() + } + + /** + * Get trial end date for display (YYYY-MM-DD) + */ + static getTrialEndDateString(startDate?: Date): string { + const start = startDate || new Date() + const endDate = new Date(start.getTime() + this.getTrialDurationMs()) + return endDate.toISOString().split('T')[0] + } + + /** + * Check if trial has expired + */ + static isTrialExpired(trialEndDate: string): boolean { + const now = new Date() + const endDate = new Date(trialEndDate) + return now > endDate + } + + /** + * Get display text for trial period + */ + static getTrialDisplayText(): string { + return TRIAL_CONFIG.displayText + } + + /** + * Get trial credits + */ + static getTrialCredits(): number { + return TRIAL_CONFIG.credits + } + + /** + * Get human-readable duration description + */ + static getDurationDescription(): string { + const { duration, unit } = TRIAL_CONFIG + const unitText = duration === 1 ? unit.slice(0, -1) : unit // Remove 's' for singular + return `${duration} ${unitText}` + } + + /** + * Get trial configuration + */ + static getTrialConfig(): TrialConfig { + return TRIAL_CONFIG + } +} + /** * Credit allocation per plan */ export const PLAN_CREDITS: Record = { - 'Free': { - total: 50, - displayName: 'Free Plan', - type: 'FREE', + 'Trial': { + total: 500, + displayName: 'Trial Plan', + type: 'TRIAL', isUnlimited: false, minimum: 0 }, @@ -70,7 +192,7 @@ export const FEATURE_COSTS = { */ export const CREDIT_THRESHOLDS: CreditThresholds = { unlimitedThreshold: 99999, - defaultInitial: 50, + defaultTrial: 500, warningThreshold: 80 // Warn when user has used 80% of credits } @@ -84,7 +206,7 @@ export class CreditConfig { static getCreditsForPlan(planName: string): PlanCredits { // Normalize plan name to match our config keys const normalizedName = this.normalizePlanName(planName) - return PLAN_CREDITS[normalizedName] || PLAN_CREDITS.Free + return PLAN_CREDITS[normalizedName] || PLAN_CREDITS.Standard // Default to Standard instead of Free } /** @@ -92,7 +214,7 @@ export class CreditConfig { */ static getCreditsByType(planType: PlanType): PlanCredits { const plan = Object.values(PLAN_CREDITS).find(p => p.type === planType) - return plan || PLAN_CREDITS.Free + return plan || PLAN_CREDITS.Standard // Default to Standard instead of Free } /** @@ -193,14 +315,17 @@ export class CreditConfig { private static normalizePlanName(planName: string): PlanName { const normalized = planName.toLowerCase().trim() + if (normalized.includes('trial')) { + return 'Trial' + } if (normalized.includes('standard')) { return 'Standard' } if (normalized.includes('pro')) { return 'Pro' } - // Default to Free for any unrecognized plan - return 'Free' + // Default to Standard (no more Free fallback) + return 'Standard' } /** @@ -219,10 +344,24 @@ export class CreditConfig { } /** - * Get the default initial credits for new users + * Get the default trial credits for new users */ - static getDefaultInitialCredits(): number { - return CREDIT_THRESHOLDS.defaultInitial + static getDefaultTrialCredits(): number { + return TrialUtils.getTrialCredits() + } + + /** + * Get trial end date (delegates to TrialUtils) + */ + static getTrialEndDate(startDate?: Date): string { + return TrialUtils.getTrialEndDateString(startDate) + } + + /** + * Check if trial has expired (delegates to TrialUtils) + */ + static isTrialExpired(trialEndDate: string): boolean { + return TrialUtils.isTrialExpired(trialEndDate) } /** @@ -237,6 +376,16 @@ export class CreditConfig { */ static getPlanName(planType: PlanType): PlanName { const plan = Object.entries(PLAN_CREDITS).find(([_, config]) => config.type === planType) - return plan ? plan[0] as PlanName : 'Free' + return plan ? plan[0] as PlanName : 'Standard' + } + + /** + * Check if user has any credits remaining + */ + static hasCreditsRemaining(used: number, total: number): boolean { + if (this.isUnlimitedTotal(total)) { + return true + } + return (total - used) > 0 } } \ No newline at end of file diff --git a/auto-analyst-frontend/lib/features/feature-access.ts b/auto-analyst-frontend/lib/features/feature-access.ts index 375c9fcb..5ace6e07 100644 --- a/auto-analyst-frontend/lib/features/feature-access.ts +++ b/auto-analyst-frontend/lib/features/feature-access.ts @@ -48,7 +48,9 @@ function isSubscriptionActive(subscription: UserSubscription | null): boolean { const tier = planToTier(subscription); if (tier === 'free') return true; - return subscription.status === 'active'; + // Accept both 'active' and 'trialing' as active statuses + // This allows trial users to access paid features immediately + return subscription.status === 'active' || subscription.status === 'trialing'; } export function hasFeatureAccess( diff --git a/auto-analyst-frontend/lib/redis.ts b/auto-analyst-frontend/lib/redis.ts index 49df4cf1..2be290da 100644 --- a/auto-analyst-frontend/lib/redis.ts +++ b/auto-analyst-frontend/lib/redis.ts @@ -1,7 +1,6 @@ import { Redis } from '@upstash/redis' import logger from '@/lib/utils/logger' -import { CreditConfig, CREDIT_THRESHOLDS } from './credits-config' - +import { CreditConfig, CREDIT_THRESHOLDS, TrialUtils } from './credits-config' // Initialize Redis client with Upstash credentials const redis = new Redis({ url: process.env.UPSTASH_REDIS_REST_URL || '', @@ -43,7 +42,8 @@ export const creditUtils = { try { const creditsHash = await redis.hgetall(KEYS.USER_CREDITS(userId)) if (!creditsHash || !creditsHash.total || !creditsHash.used) { - return CreditConfig.getDefaultInitialCredits() + // No more free credits - users must have 0 credits if no subscription + return 0 } const total = parseInt(creditsHash.total as string) @@ -61,19 +61,36 @@ export const creditUtils = { } }, - // Initialize credits for a new user - async initializeCredits(userId: string, credits: number = CreditConfig.getDefaultInitialCredits()): Promise { + // Initialize credits for a trial user (500 credits) + async initializeTrialCredits(userId: string, paymentIntentId: string, trialEndDate: string): Promise { try { - const resetDate = this.getOneMonthFromToday() + const trialCredits = TrialUtils.getTrialCredits() await redis.hset(KEYS.USER_CREDITS(userId), { - total: credits.toString(), + total: trialCredits.toString(), used: '0', - resetDate: resetDate, - lastUpdate: new Date().toISOString() + resetDate: trialEndDate, + lastUpdate: new Date().toISOString(), + isTrialCredits: 'true', + paymentIntentId: paymentIntentId + }) + } catch (error) { + console.error('Error initializing trial credits:', error) + } + }, + + // Set credits to 0 for downgraded users + async setZeroCredits(userId: string): Promise { + try { + await redis.hset(KEYS.USER_CREDITS(userId), { + total: '0', + used: '0', + resetDate: '', + lastUpdate: new Date().toISOString(), + downgradedAt: new Date().toISOString() }) } catch (error) { - console.error('Error initializing credits:', error) + console.error('Error setting zero credits:', error) } }, @@ -106,11 +123,8 @@ export const creditUtils = { return true; } - // Initialize credits if not found - await this.initializeCredits(userId); - - // Check if we have enough of the initial credits using credit config - return amount <= CreditConfig.getDefaultInitialCredits(); + // No credits to initialize for users without subscription + return false; } catch (error) { console.error('Error deducting credits:', error); return true; // Failsafe @@ -321,12 +335,6 @@ export const subscriptionUtils = { return false; } - // Check if this is a Free plan (if no subscription data or planType is FREE) - const isFree = !subscriptionData || - !subscriptionData.planType || - subscriptionData.planType === 'FREE' || - (subscriptionData.plan && (subscriptionData.plan as string).includes('Free')); - // Check if the subscription is pending cancellation/downgrade or inactive const isPendingDowngrade = (subscriptionData && ( @@ -334,15 +342,25 @@ export const subscriptionUtils = { subscriptionData.status === 'inactive' )) || (creditsData && creditsData.pendingDowngrade === 'true'); - - // For Free plans, we should consider them as 'active' for credits refresh purposes - // Also, subscriptions in 'canceling' or 'inactive' state should still get their credits refreshed - const shouldProcess = isFree || - (subscriptionData && ( - subscriptionData.status === 'active' || - subscriptionData.status === 'canceling' || - subscriptionData.status === 'inactive' - )); + + // Check if subscription is canceled (including trial cancellations) + const isCanceled = subscriptionData && subscriptionData.status === 'canceled'; + + // Process active subscriptions, trials, and pending downgrades/cancellations + const shouldProcess = subscriptionData && ( + subscriptionData.status === 'active' || + subscriptionData.status === 'trialing' || // Fixed: was 'trial' + subscriptionData.status === 'canceling' || + subscriptionData.status === 'inactive' || + subscriptionData.status === 'canceled' || // Added: handle canceled trials + isPendingDowngrade + ); + + // Special handling for canceled subscriptions - immediately set to 0 credits + if (isCanceled) { + await creditUtils.setZeroCredits(userId); + return true; + } // Treat all plans (including Free) similarly for credit refreshes if (shouldProcess) { @@ -363,13 +381,23 @@ export const subscriptionUtils = { } // Determine credit amount using centralized config - let creditAmount = CreditConfig.getCreditsForPlan('Free').total; // Default free plan + let creditAmount = 0; // Default to 0 (no more free plan) if (isPendingDowngrade || (subscriptionData && subscriptionData.status === 'inactive')) { - // If inactive or pending downgrade, use Free plan credits - creditAmount = CreditConfig.getCreditsForPlan('Free').total; - } else if (!isFree) { - // Use centralized config for plan type lookup + // Check if there's a specific next credit amount set + if (creditsData.nextTotalCredits) { + creditAmount = parseInt(creditsData.nextTotalCredits as string); + } else { + // Default to 0 for cancelled/inactive subscriptions + creditAmount = 0; + } + } else if (subscriptionData && subscriptionData.status === 'active') { + // Use centralized config for active plan type lookup + const planType = subscriptionData.planType as string; + const planCredits = CreditConfig.getCreditsByType(planType as any); + creditAmount = planCredits.total; + } else if (subscriptionData && subscriptionData.status === 'trialing') { + // Use centralized config for trialing subscriptions (should get trial credits) const planType = subscriptionData.planType as string; const planCredits = CreditConfig.getCreditsByType(planType as any); creditAmount = planCredits.total; @@ -378,8 +406,17 @@ export const subscriptionUtils = { // If we've passed the reset date, refresh credits // This applies to both free and paid plans if (!resetDate || now >= resetDate) { - // Calculate next reset date using centralized function - const nextResetDate = CreditConfig.getNextResetDate(); + // Calculate next reset date - preserve individual user's reset schedule + let nextResetDate; + if (resetDate) { + // If user has existing reset date, advance it by one month + const nextReset = new Date(resetDate); + nextReset.setMonth(nextReset.getMonth() + 1); + nextResetDate = nextReset.toISOString().split('T')[0]; + } else { + // For new users or corrupted data, use checkout-based reset (1 month from now) + nextResetDate = CreditConfig.getNextResetDate(); + } // Prepare credit data - remove pendingDowngrade and nextTotalCredits if present const newCreditData: any = { @@ -407,7 +444,8 @@ export const subscriptionUtils = { // Save to Redis (only update credit data if not fully downgraded) if (!subscriptionData || ( subscriptionData.status !== 'canceling' && - subscriptionData.status !== 'inactive' + subscriptionData.status !== 'inactive' && + subscriptionData.status !== 'canceled' // Don't refresh credits for canceled users )) { await redis.hset(KEYS.USER_CREDITS(userId), newCreditData); } diff --git a/auto-analyst-frontend/lib/server/redis-server.ts b/auto-analyst-frontend/lib/server/redis-server.ts index ae9019cf..26b6953b 100644 --- a/auto-analyst-frontend/lib/server/redis-server.ts +++ b/auto-analyst-frontend/lib/server/redis-server.ts @@ -21,11 +21,11 @@ export const serverCreditUtils = { return total - used; } - // Default for new users using centralized config - return CreditConfig.getDefaultInitialCredits(); + // No free credits anymore - users need to pay for trial + return 0; } catch (error) { console.error('Server Redis error fetching credits:', error); - return CreditConfig.getDefaultInitialCredits(); // Default fallback using centralized config + return 0; // No free credits anymore - users need to pay for trial } } }; diff --git a/auto-analyst-frontend/package-lock.json b/auto-analyst-frontend/package-lock.json index 23cdc29c..4e832bbb 100644 --- a/auto-analyst-frontend/package-lock.json +++ b/auto-analyst-frontend/package-lock.json @@ -51,6 +51,7 @@ "crypto-js": "^4.2.0", "date-fns": "^4.1.0", "diff-match-patch": "^1.0.5", + "dotenv": "^16.5.0", "firebase": "^11.3.1", "framer-motion": "^10.18.0", "lucide-react": "^0.474.0", @@ -8386,6 +8387,18 @@ "csstype": "^3.0.2" } }, + "node_modules/dotenv": { + "version": "16.5.0", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.5.0.tgz", + "integrity": "sha512-m/C+AwOAr9/W1UOIZUo232ejMNnJAJtYQjUbHoNTBNTJSvqzzDh7vnrei3o3r3m9blf6ZoDkvcw0VmozNRFJxg==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, "node_modules/draw-svg-path": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/draw-svg-path/-/draw-svg-path-1.0.0.tgz", diff --git a/auto-analyst-frontend/package.json b/auto-analyst-frontend/package.json index b2818b9d..6866132b 100644 --- a/auto-analyst-frontend/package.json +++ b/auto-analyst-frontend/package.json @@ -52,6 +52,7 @@ "crypto-js": "^4.2.0", "date-fns": "^4.1.0", "diff-match-patch": "^1.0.5", + "dotenv": "^16.5.0", "firebase": "^11.3.1", "framer-motion": "^10.18.0", "lucide-react": "^0.474.0", diff --git a/auto-analyst-frontend/{ b/auto-analyst-frontend/{ new file mode 100644 index 00000000..e69de29b From d4062a5cd38531be65f9aae5e65e12ff8b3c7220 Mon Sep 17 00:00:00 2001 From: Ashad Qureshi Date: Sun, 22 Jun 2025 23:57:49 +0500 Subject: [PATCH 2/4] Trial Plan v2.0 --- auto-analyst-frontend/app/account/page.tsx | 26 +++++ .../app/api/debug/force-zero-credits/route.ts | 79 ++++++++++++++++ .../app/api/trial/cancel/route.ts | 10 ++ .../components/chat/ChatInput.tsx | 94 +++++++++++++++---- .../components/chat/ChatInterface.tsx | 2 + .../components/chat/CreditExhaustedModal.tsx | 67 +++++++++++++ auto-analyst-frontend/lib/redis.ts | 42 ++++----- 7 files changed, 280 insertions(+), 40 deletions(-) create mode 100644 auto-analyst-frontend/app/api/debug/force-zero-credits/route.ts create mode 100644 auto-analyst-frontend/components/chat/CreditExhaustedModal.tsx diff --git a/auto-analyst-frontend/app/account/page.tsx b/auto-analyst-frontend/app/account/page.tsx index 0fe5cb4a..a9357edf 100644 --- a/auto-analyst-frontend/app/account/page.tsx +++ b/auto-analyst-frontend/app/account/page.tsx @@ -191,6 +191,32 @@ export default function AccountPage() { } }, [searchParams, router, refreshUserData]) + // Effect to emit credit update event when user navigates away from accounts page + useEffect(() => { + // Function to handle navigation away from accounts page + const handleBeforeUnload = () => { + // Emit custom event to notify other components that credits may have been updated + window.dispatchEvent(new CustomEvent('creditsUpdated')); + }; + + // Function to handle visibility change (user switches tabs/windows) + const handleVisibilityChange = () => { + if (document.hidden) { + // User is leaving the page/tab, emit the event + window.dispatchEvent(new CustomEvent('creditsUpdated')); + } + }; + + // Add event listeners + window.addEventListener('beforeunload', handleBeforeUnload); + document.addEventListener('visibilitychange', handleVisibilityChange); + + return () => { + window.removeEventListener('beforeunload', handleBeforeUnload); + document.removeEventListener('visibilitychange', handleVisibilityChange); + }; + }, []) + useEffect(() => { // Add CSS for custom toggle switches const style = document.createElement('style'); diff --git a/auto-analyst-frontend/app/api/debug/force-zero-credits/route.ts b/auto-analyst-frontend/app/api/debug/force-zero-credits/route.ts new file mode 100644 index 00000000..f5e75bed --- /dev/null +++ b/auto-analyst-frontend/app/api/debug/force-zero-credits/route.ts @@ -0,0 +1,79 @@ +import { NextRequest, NextResponse } from 'next/server' +import { getToken } from 'next-auth/jwt' +import redis, { creditUtils, KEYS, subscriptionUtils } from '@/lib/redis' + +export const dynamic = 'force-dynamic' + +export async function POST(request: NextRequest) { + try { + // Get the user token + const token = await getToken({ req: request }) + if (!token?.sub) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const userId = token.sub + + console.log(`[DEBUG] Force zeroing credits for user ${userId}`) + + // Get current state before + const beforeCredits = await redis.hgetall(KEYS.USER_CREDITS(userId)) + const beforeSubscription = await redis.hgetall(KEYS.USER_SUBSCRIPTION(userId)) + + console.log('Before - Credits:', beforeCredits) + console.log('Before - Subscription:', beforeSubscription) + + // Force set zero credits multiple ways + await creditUtils.setZeroCredits(userId) + + // Also force via direct Redis + const now = new Date() + await redis.hset(KEYS.USER_CREDITS(userId), { + total: '0', + used: '0', + resetDate: '', + lastUpdate: now.toISOString(), + downgradedAt: now.toISOString(), + forcedZero: 'true' + }) + + // Get state after + const afterCredits = await redis.hgetall(KEYS.USER_CREDITS(userId)) + const afterSubscription = await redis.hgetall(KEYS.USER_SUBSCRIPTION(userId)) + + console.log('After - Credits:', afterCredits) + console.log('After - Subscription:', afterSubscription) + + // Test the refresh logic + const refreshResult = await subscriptionUtils.refreshCreditsIfNeeded(userId) + console.log('Refresh result:', refreshResult) + + // Get final state + const finalCredits = await redis.hgetall(KEYS.USER_CREDITS(userId)) + console.log('Final - Credits:', finalCredits) + + return NextResponse.json({ + success: true, + userId, + before: { + credits: beforeCredits, + subscription: beforeSubscription + }, + after: { + credits: afterCredits, + subscription: afterSubscription + }, + final: { + credits: finalCredits + }, + refreshResult, + timestamp: now.toISOString() + }) + + } catch (error: any) { + console.error('Error forcing zero credits:', error) + return NextResponse.json({ + error: error.message || 'Failed to force zero credits' + }, { status: 500 }) + } +} \ No newline at end of file diff --git a/auto-analyst-frontend/app/api/trial/cancel/route.ts b/auto-analyst-frontend/app/api/trial/cancel/route.ts index d06bb160..57e21000 100644 --- a/auto-analyst-frontend/app/api/trial/cancel/route.ts +++ b/auto-analyst-frontend/app/api/trial/cancel/route.ts @@ -72,6 +72,16 @@ export async function POST(request: NextRequest) { // Immediately set credits to 0 (lose access immediately as requested) await creditUtils.setZeroCredits(userId) + // Force additional credit zeroing to be absolutely sure + await redis.hset(KEYS.USER_CREDITS(userId), { + total: '0', + used: '0', + resetDate: '', + lastUpdate: now.toISOString(), + downgradedAt: now.toISOString(), + canceledAt: now.toISOString() + }) + // Verify credits were actually set to 0 const creditsAfterReset = await redis.hgetall(KEYS.USER_CREDITS(userId)) console.log(`Credits after reset for user ${userId}:`, creditsAfterReset) diff --git a/auto-analyst-frontend/components/chat/ChatInput.tsx b/auto-analyst-frontend/components/chat/ChatInput.tsx index 0ef71b42..167c80e8 100644 --- a/auto-analyst-frontend/components/chat/ChatInput.tsx +++ b/auto-analyst-frontend/components/chat/ChatInput.tsx @@ -23,6 +23,7 @@ import { useCredits } from '@/lib/contexts/credit-context' import API_URL from '@/config/api' import Link from 'next/link' import DatasetResetPopup from './DatasetResetPopup' +import CreditExhaustedModal from './CreditExhaustedModal' import ReactMarkdown from 'react-markdown' import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" import logger from '@/lib/utils/logger' @@ -234,6 +235,9 @@ const ChatInput = forwardRef< // Get subscription from store instead of manual construction const { subscription } = useUserSubscriptionStore() const deepAnalysisAccess = useFeatureAccess('DEEP_ANALYSIS', subscription) + + // Credit exhausted modal state + const [showCreditExhaustedModal, setShowCreditExhaustedModal] = useState(false) // Expose handlePreviewDefaultDataset to parent useImperativeHandle(ref, () => ({ @@ -252,6 +256,50 @@ const ChatInput = forwardRef< checkDisabledStatus(); }, []); + // Add credit refresh on navigation from accounts page + useEffect(() => { + // Listen for focus events to detect when user returns from accounts page + const handleWindowFocus = () => { + // Check if we just came from accounts page by checking document referrer + const referrer = document.referrer; + const isFromAccountsPage = referrer.includes('/account') || referrer.includes('/pricing'); + + if (isFromAccountsPage && session) { + // Refresh credits when coming back from accounts/pricing page + setTimeout(() => { + checkCredits(); + }, 1000); // Small delay to ensure any backend processes have completed + } + }; + + // Also listen for storage events (in case accounts page updates localStorage) + const handleStorageChange = (e: StorageEvent) => { + if (e.key?.includes('credits') || e.key?.includes('subscription')) { + // Credits or subscription data changed, refresh + setTimeout(() => { + checkCredits(); + }, 500); + } + }; + + // Listen for custom events from other parts of the app + const handleCreditUpdate = () => { + setTimeout(() => { + checkCredits(); + }, 500); + }; + + window.addEventListener('focus', handleWindowFocus); + window.addEventListener('storage', handleStorageChange); + window.addEventListener('creditsUpdated', handleCreditUpdate); + + return () => { + window.removeEventListener('focus', handleWindowFocus); + window.removeEventListener('storage', handleStorageChange); + window.removeEventListener('creditsUpdated', handleCreditUpdate); + }; + }, [session, checkCredits]); + // Add a periodic check for credit state to ensure UI is consistent useEffect(() => { // Skip this check for non-logged in users @@ -274,6 +322,18 @@ const ChatInput = forwardRef< return () => clearInterval(intervalId); }, [session, isChatBlocked]); + // Auto-show credit exhausted modal when credits become 0 + useEffect(() => { + if (isChatBlocked && remainingCredits <= 0 && session) { + // Auto-show modal after a short delay when chat becomes blocked + const timer = setTimeout(() => { + setShowCreditExhaustedModal(true); + }, 1000); + + return () => clearTimeout(timer); + } + }, [isChatBlocked, remainingCredits, session]); + // Add an improved effect to handle chat switches and preserve dataset info useEffect(() => { // When sessionId changes (switching chats), check for dataset info @@ -1915,30 +1975,24 @@ const ChatInput = forwardRef<
- {/* Credit exhaustion message with reset date */} - {isChatBlocked && ( + {/* Show credit exhausted modal when chat is blocked */} + {isChatBlocked && !showCreditExhaustedModal && (
-
+
- - You've used all your tokens for this month + + Credits Required
-

- Upgrade your plan to get more tokens immediately, or wait until {getResetDate()} when your free tokens will reset. +

+ You need credits to continue using Auto-Analyst.

- - -
@@ -2384,6 +2438,12 @@ const ChatInput = forwardRef< userId={userId} forceExpanded={shouldForceExpanded} /> */} + + {/* Credit Exhausted Modal */} + setShowCreditExhaustedModal(false)} + /> ) }) diff --git a/auto-analyst-frontend/components/chat/ChatInterface.tsx b/auto-analyst-frontend/components/chat/ChatInterface.tsx index 99f983ae..bc745a09 100644 --- a/auto-analyst-frontend/components/chat/ChatInterface.tsx +++ b/auto-analyst-frontend/components/chat/ChatInterface.tsx @@ -1480,6 +1480,8 @@ const ChatInterface: React.FC = () => { }, [activeChatId, chatHistories, clearMessages, createNewChat, loadChat, userId, sessionId]); const handleNavigateToAccount = useCallback(() => { + // Emit event that user is going to accounts page (may update credits there) + window.dispatchEvent(new CustomEvent('navigatingToAccounts')); router.push('/account'); setIsUserProfileOpen(false); }, [router, setIsUserProfileOpen]); diff --git a/auto-analyst-frontend/components/chat/CreditExhaustedModal.tsx b/auto-analyst-frontend/components/chat/CreditExhaustedModal.tsx new file mode 100644 index 00000000..2988c12a --- /dev/null +++ b/auto-analyst-frontend/components/chat/CreditExhaustedModal.tsx @@ -0,0 +1,67 @@ +'use client' + +import React from 'react' +import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog' +import { Button } from '@/components/ui/button' +import { Lock, Crown, CreditCard } from 'lucide-react' +import Link from 'next/link' +// import config +import { TrialUtils } from '@/lib/credits-config' + +interface CreditExhaustedModalProps { + isOpen: boolean + onClose: () => void +} + +export default function CreditExhaustedModal({ + isOpen, + onClose +}: CreditExhaustedModalProps) { + return ( + + + + + + Credits Required + + +
+
+ +
+

Upgrade Required

+

+ You need credits to continue using Auto-Analyst. Choose a plan that fits your needs. +

+
+
+ +
+
Available options:
+
    +
  • • Start a {TrialUtils.getTrialDisplayText()} free trial with {TrialUtils.getTrialCredits()} credits
  • +
  • • Upgrade to Standard plan for 500 credits/month
  • +
  • • Contact us for enterprise plans
  • +
+
+
+ + + + +
+
+
+
+ ) +} \ No newline at end of file diff --git a/auto-analyst-frontend/lib/redis.ts b/auto-analyst-frontend/lib/redis.ts index 2be290da..0bb8bd1c 100644 --- a/auto-analyst-frontend/lib/redis.ts +++ b/auto-analyst-frontend/lib/redis.ts @@ -344,7 +344,10 @@ export const subscriptionUtils = { (creditsData && creditsData.pendingDowngrade === 'true'); // Check if subscription is canceled (including trial cancellations) - const isCanceled = subscriptionData && subscriptionData.status === 'canceled'; + const isCanceled = subscriptionData && ( + subscriptionData.status === 'canceled' || + subscriptionData.status === 'canceling' + ); // Process active subscriptions, trials, and pending downgrades/cancellations const shouldProcess = subscriptionData && ( @@ -356,9 +359,10 @@ export const subscriptionUtils = { isPendingDowngrade ); - // Special handling for canceled subscriptions - immediately set to 0 credits + // Special handling for canceled/canceling subscriptions - immediately set to 0 credits if (isCanceled) { await creditUtils.setZeroCredits(userId); + console.log(`[Credits] Set zero credits for ${subscriptionData.status} user ${userId}`); return true; } @@ -484,39 +488,31 @@ export const subscriptionUtils = { async downgradeToFreePlan(userId: string): Promise { try { const now = new Date(); - const resetDate = CreditConfig.getNextResetDate(); - - // Get current credits used to preserve them - const currentCredits = await redis.hgetall(KEYS.USER_CREDITS(userId)); - const usedCredits = currentCredits && currentCredits.used - ? parseInt(currentCredits.used as string) - : 0; - - // Get Free plan configuration - const freeCredits = CreditConfig.getCreditsForPlan('Free'); - // Update subscription to Free plan + // Since we removed the free plan, downgraded users get 0 credits await redis.hset(KEYS.USER_SUBSCRIPTION(userId), { - plan: freeCredits.displayName, - planType: freeCredits.type, - status: 'active', + plan: 'No Active Plan', + planType: 'DOWNGRADED', + status: 'canceled', amount: '0', interval: 'month', renewalDate: '', lastUpdated: now.toISOString(), stripeCustomerId: '', - stripeSubscriptionId: '' + stripeSubscriptionId: '', + downgraded: 'true' }); - // Update credits to Free plan level, but preserve used credits + // Set credits to 0 for downgraded users (no free plan anymore) await redis.hset(KEYS.USER_CREDITS(userId), { - total: freeCredits.total.toString(), - used: Math.min(usedCredits, freeCredits.total).toString(), // Used credits shouldn't exceed new total - resetDate: resetDate, - lastUpdate: now.toISOString() + total: '0', + used: '0', + resetDate: '', + lastUpdate: now.toISOString(), + downgraded: 'true' }); - logger.log(`User ${userId} downgraded to Free plan with ${freeCredits.total} credits`); + logger.log(`User ${userId} downgraded to 0 credits (no free plan)`); return true; } catch (error) { console.error('Error downgrading to free plan:', error); From f71591a5b837e4760f388644da5ec4406f30d113 Mon Sep 17 00:00:00 2001 From: Ashad Qureshi Date: Mon, 23 Jun 2025 19:17:24 +0500 Subject: [PATCH 3/4] Stripe Handling Improved - Trial vs Active Plans Tested --- TRIAL_SYSTEM_FINAL.md | 200 +++++++++ auto-analyst-frontend/app/account/page.tsx | 73 ++- .../app/api/checkout-sessions/route.ts | 17 + .../app/api/debug/sync-subscription/route.ts | 109 +++++ .../app/api/payment-intent-details/route.ts | 48 -- .../app/api/trial/cancel/route.ts | 97 ++-- .../app/api/trial/start/route.ts | 97 ++++ .../app/api/user/data/route.ts | 1 + .../app/api/verify-payment/route.ts | 418 ------------------ .../app/api/webhooks/route.ts | 398 +++++++++-------- auto-analyst-frontend/app/chat/page.tsx | 44 ++ .../app/checkout/success/page.tsx | 36 +- .../components/CheckoutForm.tsx | 34 +- .../components/chat/ChatInput.tsx | 13 +- .../components/chat/CreditBalance.tsx | 25 +- auto-analyst-frontend/components/layout.tsx | 14 +- auto-analyst-frontend/docs/README.md | 46 ++ auto-analyst-frontend/lib/redis.ts | 25 +- auto-analyst-frontend/lib/utils/logger.ts | 2 +- 19 files changed, 937 insertions(+), 760 deletions(-) create mode 100644 TRIAL_SYSTEM_FINAL.md create mode 100644 auto-analyst-frontend/app/api/debug/sync-subscription/route.ts delete mode 100644 auto-analyst-frontend/app/api/payment-intent-details/route.ts delete mode 100644 auto-analyst-frontend/app/api/verify-payment/route.ts create mode 100644 auto-analyst-frontend/docs/README.md diff --git a/TRIAL_SYSTEM_FINAL.md b/TRIAL_SYSTEM_FINAL.md new file mode 100644 index 00000000..b08b4991 --- /dev/null +++ b/TRIAL_SYSTEM_FINAL.md @@ -0,0 +1,200 @@ +# 🔥 **Auto-Analyst Trial System - Final Architecture** + +## 📋 **System Overview** + +The Auto-Analyst app now uses a **7-day trial system** where: +- ❌ **No Free Plan** - Users get 0 credits without subscription +- ✅ **Trial Required** - All new users must authorize payment to access features +- 💳 **Payment After Trial** - Stripe charges at day 7 unless canceled +- 🛡️ **Webhook Protected** - All logic handled via Stripe webhooks + +--- + +## 🔄 **Complete User Flow** + +### **1. Checkout Flow** +``` +User clicks "Start Trial" + ↓ +Checkout page (/checkout) + ↓ +Stripe subscription with 7-day trial + ↓ +Payment method authorization (no charge) + ↓ +Redirect to /checkout/success + ↓ +POST /api/trial/start + ↓ +500 credits granted immediately + ↓ +Redirect to /account +``` + +### **2. Trial Cancellation Flow** +``` +During Trial (0-7 days): +User cancels → Credits = 0 immediately → No charge ever + +After Trial (7+ days): +User cancels → Keep access until month end → Final cleanup via webhook +``` + +### **3. Payment Capture Flow** +``` +Day 7: Stripe auto-captures payment + ↓ +invoice.payment_succeeded webhook + ↓ +Status: trialing → active + ↓ +User keeps 500 credits for full month +``` + +--- + +## 🛠️ **API Endpoints** + +### **Core Endpoints** +- `POST /api/checkout-sessions` - Creates Stripe subscription with trial +- `POST /api/trial/start` - Grants trial access after payment auth +- `POST /api/trial/cancel` - Cancels trial (immediate) or subscription (period end) + +### **Removed Endpoints** ✅ +- ❌ `/api/verify-payment` - No longer needed (trial-only system) +- ❌ `/api/payment-intent-details` - Not used anymore + +--- + +## 🎯 **Webhook Handlers** + +### **Essential Webhooks** ✅ +1. **`checkout.session.completed`** - Logs checkout completion +2. **`customer.subscription.updated`** - Syncs subscription status changes +3. **`customer.subscription.deleted`** - Final cleanup, sets credits to 0 +4. **`customer.subscription.trial_will_end`** - Optional reminder emails +5. **`invoice.payment_succeeded`** - Trial → Active conversion +6. **`invoice.payment_failed`** - Handle failed payment after trial + +### **Failure Protection Webhooks** 🛡️ +7. **`payment_intent.payment_failed`** - Prevents trial if payment auth fails +8. **`payment_intent.canceled`** - Prevents trial if user cancels during checkout +9. **`setup_intent.setup_failed`** - Prevents trial if payment method setup fails +10. **`payment_intent.requires_action`** - Logs 3D Secure requirements + +--- + +## 💾 **Redis Data Structure** + +### **User Subscription (`user:subscription:{userId}`)** +```json +{ + "plan": "Standard Plan", + "planType": "STANDARD", + "status": "trialing|active|canceled|past_due", + "amount": "15", + "interval": "month", + "purchaseDate": "2025-01-XX", + "trialStartDate": "2025-01-XX", + "trialEndDate": "2025-01-XX", + "stripeSubscriptionId": "sub_xxx", + "stripeCustomerId": "cus_xxx" +} +``` + +### **User Credits (`user:credits:{userId}`)** +```json +{ + "total": "500", + "used": "0", + "resetDate": "2025-02-XX", + "lastUpdate": "2025-01-XX" +} +``` + +--- + +## 🔒 **Security & Validation** + +### **Trial Access Protection** +- ✅ Stripe subscription verification before granting access +- ✅ Payment method authorization required +- ✅ Webhook metadata validation +- ✅ Real-time payment failure handling + +### **Cancellation Protection** +- ✅ Immediate access removal for trial cancellations +- ✅ Period-end access for post-trial cancellations +- ✅ No new charges after cancellation +- ✅ Complete data cleanup + +--- + +## 📊 **Credit System** + +### **Credit Allocation** +- **Trial Users**: 500 credits immediately +- **Active Subscribers**: 500 credits/month +- **Canceled Users**: 0 credits +- **No Subscription**: 0 credits + +### **Reset Logic** +- **Trial**: Credits reset 1 month from signup (not trial end) +- **Active**: Standard monthly reset on 1st of month +- **Canceled**: No resets + +--- + +## 🚨 **Failure Scenarios** + +| **Scenario** | **Handler** | **Result** | +|-------------|-------------|------------| +| 💳 Card declined during signup | `payment_intent.payment_failed` | No trial access | +| ❌ User cancels payment | `payment_intent.canceled` | No trial access | +| 🔐 3D Secure fails | `setup_intent.setup_failed` | No trial access | +| ⏰ Day 7 payment fails | `invoice.payment_failed` | Credits → 0 | +| 🚫 User cancels trial | `/api/trial/cancel` | Immediate access removal | +| 📅 User cancels after trial | `/api/trial/cancel` | Access until period end | + +--- + +## ✅ **System Validation Checklist** + +### **Checkout Flow** +- [x] All checkouts create trial subscriptions +- [x] Payment authorization required (no immediate charge) +- [x] Trial access granted only after successful auth +- [x] Immediate 500 credits with Standard plan access +- [x] Webhook-driven (no fallback frontend logic) + +### **Cancellation Flow** +- [x] Trial cancellation = immediate access removal +- [x] Post-trial cancellation = access until period end +- [x] No charges after cancellation +- [x] Complete Redis cleanup + +### **Security** +- [x] Payment failures prevent trial access +- [x] Subscription verification before granting access +- [x] Webhook metadata validation +- [x] No free plan fallbacks + +### **Data Consistency** +- [x] Redis accurately reflects Stripe state +- [x] No duplicate subscription handling +- [x] Proper credit reset scheduling +- [x] Clean subscription deletion + +--- + +## 🎉 **Key Benefits** + +1. **💰 Revenue Protection**: No free access without payment method +2. **🛡️ Fraud Prevention**: Real payment authorization required +3. **⚡ Instant Access**: Immediate trial experience after auth +4. **🔄 Automated Billing**: Stripe handles recurring payments +5. **📊 Clean Data**: Single source of truth in Stripe + Redis sync +6. **🚫 No Abuse**: Trial requires valid payment method +7. **📈 Higher Conversion**: Commitment through payment auth + +The system is now **production-ready** with comprehensive error handling and security measures! 🚀 \ No newline at end of file diff --git a/auto-analyst-frontend/app/account/page.tsx b/auto-analyst-frontend/app/account/page.tsx index a9357edf..7ebeb0f0 100644 --- a/auto-analyst-frontend/app/account/page.tsx +++ b/auto-analyst-frontend/app/account/page.tsx @@ -40,6 +40,7 @@ interface UserProfile { interface Subscription { plan: string; status: string; + displayStatus?: string; renewalDate?: string; amount: number; interval: string; @@ -137,6 +138,23 @@ export default function AccountPage() { const refreshUserData = async () => { setIsRefreshing(true) try { + // First sync subscription status from Stripe if available + let syncResult = null + try { + const syncRes = await fetch('/api/debug/sync-subscription', { + method: 'POST', + headers: { 'Content-Type': 'application/json' } + }) + syncResult = await syncRes.json() + + if (syncResult.success) { + console.log(`Subscription synced: ${syncResult.before.redisStatus} → ${syncResult.after.stripeStatus}`) + } + } catch (syncError) { + console.log('Subscription sync not available or failed:', syncError) + // Continue with regular refresh even if sync fails + } + // Add a timestamp parameter to bypass cache const timestamp = new Date().getTime() const response = await fetch(`/api/user/data?_t=${timestamp}&refresh=true`) @@ -151,10 +169,15 @@ export default function AccountPage() { setCredits(freshData.credits) setLastUpdated(new Date()) + // Enhanced toast message if subscription was synced + const syncMessage = syncResult && syncResult.success + ? ` Subscription status synced: ${syncResult.before.redisStatus} → ${syncResult.after.stripeStatus}` + : '' + toast({ title: 'Data refreshed', - description: 'Your account information has been updated', - duration: 3000 + description: `Your account information has been updated.${syncMessage}`, + duration: 4000 }) } catch (error) { console.error('Error refreshing user data:', error) @@ -242,11 +265,19 @@ export default function AccountPage() { return Inactive } else if (status === 'canceling') { return Canceling + } else if (status === 'trialing') { + return Trial + } else if (status === 'canceled') { + return Canceled } else { return {status} } }; + const getCurrentSubscriptionStatus = () => { + return subscription?.displayStatus || subscription?.status || 'inactive' + }; + const renderCreditsOverview = () => { if (!credits) { return ( @@ -332,6 +363,8 @@ export default function AccountPage() { Check Redis Data + +
Status: - {getSubscriptionStatusDisplay(subscription?.status || 'active')} + {getSubscriptionStatusDisplay(getCurrentSubscriptionStatus())}
Price: @@ -690,10 +724,10 @@ export default function AccountPage() {
+ onClick={() => router.push('/pricing')} + > + {getCurrentSubscriptionStatus() === 'active' || getCurrentSubscriptionStatus() === 'canceling' ? 'Change Plan' : 'Upgrade Now'} + @@ -787,11 +826,17 @@ export default function AccountPage() {

- {subscription?.status === 'active' ? 'Active' : 'Inactive'} + {getCurrentSubscriptionStatus() === 'active' ? 'Active' : + getCurrentSubscriptionStatus() === 'canceling' ? 'Canceling' : + getCurrentSubscriptionStatus() === 'trialing' ? 'Trial' : 'Inactive'}
diff --git a/auto-analyst-frontend/app/api/checkout-sessions/route.ts b/auto-analyst-frontend/app/api/checkout-sessions/route.ts index 29cc1763..fda547bd 100644 --- a/auto-analyst-frontend/app/api/checkout-sessions/route.ts +++ b/auto-analyst-frontend/app/api/checkout-sessions/route.ts @@ -127,6 +127,19 @@ export async function POST(request: NextRequest) { const invoice = subscription.latest_invoice as Stripe.Invoice const paymentIntent = invoice.payment_intent as Stripe.PaymentIntent + // If there's a payment intent, update its metadata for webhook handling + if (paymentIntent && paymentIntent.id) { + await stripe.paymentIntents.update(paymentIntent.id, { + metadata: { + userId: userId || 'anonymous', + isTrial: 'true', + planName, + interval, + subscription_id: subscription.id, + }, + }) + } + // If no payment intent, create a setup intent for payment method collection let clientSecret = paymentIntent?.client_secret let paymentIntentId = paymentIntent?.id @@ -139,6 +152,10 @@ export async function POST(request: NextRequest) { metadata: { subscription_id: subscription.id, is_trial_setup: 'true', + userId: userId || 'anonymous', + isTrial: 'true', + planName, + interval, }, }) clientSecret = setupIntent.client_secret diff --git a/auto-analyst-frontend/app/api/debug/sync-subscription/route.ts b/auto-analyst-frontend/app/api/debug/sync-subscription/route.ts new file mode 100644 index 00000000..e06bd06f --- /dev/null +++ b/auto-analyst-frontend/app/api/debug/sync-subscription/route.ts @@ -0,0 +1,109 @@ +import { NextRequest, NextResponse } from 'next/server' +import { getToken } from 'next-auth/jwt' +import Stripe from 'stripe' +import redis, { KEYS } from '@/lib/redis' + +export const dynamic = 'force-dynamic' + +// Initialize Stripe +const stripe = process.env.STRIPE_SECRET_KEY + ? new Stripe(process.env.STRIPE_SECRET_KEY, { + apiVersion: '2025-02-24.acacia', + }) + : null + +export async function POST(request: NextRequest) { + try { + // Get the user token + const token = await getToken({ req: request }) + if (!token?.sub) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + if (!stripe) { + return NextResponse.json({ error: 'Stripe configuration error' }, { status: 500 }) + } + + const userId = token.sub + + // Get current subscription data from Redis + const currentSubscriptionData = await redis.hgetall(KEYS.USER_SUBSCRIPTION(userId)) + + if (!currentSubscriptionData || !currentSubscriptionData.stripeSubscriptionId) { + return NextResponse.json({ error: 'No subscription found in Redis' }, { status: 400 }) + } + + const stripeSubscriptionId = currentSubscriptionData.stripeSubscriptionId as string + + console.log(`Syncing subscription ${stripeSubscriptionId} for user ${userId}`) + + // Get current status from Stripe + const subscription = await stripe.subscriptions.retrieve(stripeSubscriptionId) + + console.log(`Stripe subscription status: ${subscription.status}`) + console.log(`Redis subscription status: ${currentSubscriptionData.status}`) + + // Check if subscription is scheduled for cancellation + const isCancelingAtPeriodEnd = subscription.cancel_at_period_end && subscription.status === 'active' + + if (isCancelingAtPeriodEnd) { + console.log(`Subscription ${stripeSubscriptionId} is set to cancel at period end`) + } + + // Update Redis with current Stripe status + const updateData: any = { + status: subscription.status, + lastUpdated: new Date().toISOString(), + stripeSubscriptionStatus: subscription.status, + syncedAt: new Date().toISOString() + } + + // Handle cancel_at_period_end flag + if (subscription.cancel_at_period_end) { + updateData.cancel_at_period_end = 'true' + updateData.willCancelAt = subscription.current_period_end + ? new Date(subscription.current_period_end * 1000).toISOString() + : new Date().toISOString() + + // Override status display for UI purposes + updateData.displayStatus = 'canceling' + } else { + updateData.cancel_at_period_end = 'false' + updateData.displayStatus = subscription.status + } + + // Handle specific status transitions + if (currentSubscriptionData.status === 'trialing' && subscription.status === 'active') { + console.log(`Trial to active transition detected during sync for user ${userId}`) + updateData.trialEndedAt = new Date().toISOString() + updateData.trialToActiveDate = new Date().toISOString() + } + + await redis.hset(KEYS.USER_SUBSCRIPTION(userId), updateData) + + console.log(`Successfully synced subscription status for user ${userId}`) + + return NextResponse.json({ + success: true, + message: 'Subscription status synced successfully', + before: { + redisStatus: currentSubscriptionData.status, + stripeSubscriptionId: stripeSubscriptionId + }, + after: { + stripeStatus: subscription.status, + redisStatus: subscription.status, + lastUpdated: updateData.lastUpdated + }, + subscription: subscription, + fullRedisData: currentSubscriptionData + }) + + } catch (error: any) { + console.error('Error syncing subscription:', error) + return NextResponse.json({ + error: error.message || 'Failed to sync subscription', + details: error + }, { status: 500 }) + } +} \ No newline at end of file diff --git a/auto-analyst-frontend/app/api/payment-intent-details/route.ts b/auto-analyst-frontend/app/api/payment-intent-details/route.ts deleted file mode 100644 index 87386c70..00000000 --- a/auto-analyst-frontend/app/api/payment-intent-details/route.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { NextRequest, NextResponse } from 'next/server' -import Stripe from 'stripe' - -// Initialize Stripe only if the secret key exists -const stripe = process.env.STRIPE_SECRET_KEY - ? new Stripe(process.env.STRIPE_SECRET_KEY, { - apiVersion: '2025-02-24.acacia', - }) - : null - -export async function GET(request: NextRequest) { - try { - // Check if Stripe is initialized - if (!stripe) { - return NextResponse.json({ error: 'Stripe configuration error' }, { status: 500 }) - } - - const searchParams = request.nextUrl.searchParams - const payment_intent = searchParams.get('payment_intent') - - if (!payment_intent) { - return NextResponse.json({ error: 'Payment intent ID is required' }, { status: 400 }) - } - - // Retrieve the payment intent - const paymentIntent = await stripe.paymentIntents.retrieve(payment_intent) - - if (!paymentIntent) { - return NextResponse.json({ error: 'Payment intent not found' }, { status: 404 }) - } - - // Return the payment intent details including metadata - return NextResponse.json({ - id: paymentIntent.id, - status: paymentIntent.status, - amount: paymentIntent.amount, - currency: paymentIntent.currency, - metadata: paymentIntent.metadata, - capture_method: paymentIntent.capture_method, - }) - } catch (error: any) { - console.error('Error retrieving payment intent details:', error) - return NextResponse.json( - { error: error.message || 'Failed to retrieve payment intent details' }, - { status: 500 } - ) - } -} \ No newline at end of file diff --git a/auto-analyst-frontend/app/api/trial/cancel/route.ts b/auto-analyst-frontend/app/api/trial/cancel/route.ts index 57e21000..1f6124a4 100644 --- a/auto-analyst-frontend/app/api/trial/cancel/route.ts +++ b/auto-analyst-frontend/app/api/trial/cancel/route.ts @@ -68,60 +68,73 @@ export async function POST(request: NextRequest) { } const now = new Date() + const isTrial = subscriptionData.status === 'trialing' - // Immediately set credits to 0 (lose access immediately as requested) - await creditUtils.setZeroCredits(userId) - - // Force additional credit zeroing to be absolutely sure - await redis.hset(KEYS.USER_CREDITS(userId), { - total: '0', - used: '0', - resetDate: '', - lastUpdate: now.toISOString(), - downgradedAt: now.toISOString(), - canceledAt: now.toISOString() - }) - - // Verify credits were actually set to 0 - const creditsAfterReset = await redis.hgetall(KEYS.USER_CREDITS(userId)) - console.log(`Credits after reset for user ${userId}:`, creditsAfterReset) - - // Update subscription to canceled status - const updatedSubscriptionData = { - ...subscriptionData, - status: 'canceled', - canceledAt: now.toISOString(), - lastUpdated: now.toISOString(), - subscriptionCanceled: 'true', - // Remove trial-specific fields - trialEndDate: '', - trialStartDate: '' + if (isTrial) { + // For trial cancellations: Immediate access removal + await creditUtils.setZeroCredits(userId) + + await redis.hset(KEYS.USER_CREDITS(userId), { + total: '0', + used: '0', + resetDate: '', + lastUpdate: now.toISOString(), + downgradedAt: now.toISOString(), + canceledAt: now.toISOString(), + trialCanceled: 'true' // Mark this as a genuine trial cancellation + }) + + // Update subscription to canceled status immediately + await redis.hset(KEYS.USER_SUBSCRIPTION(userId), { + ...subscriptionData, + status: 'canceled', + canceledAt: now.toISOString(), + lastUpdated: now.toISOString(), + subscriptionCanceled: 'true', + trialEndDate: '', + trialStartDate: '' + }) + + console.log(`Trial 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 + // The customer.subscription.deleted webhook will handle final cleanup + + await redis.hset(KEYS.USER_SUBSCRIPTION(userId), { + ...subscriptionData, + status: 'cancel_at_period_end', + canceledAt: now.toISOString(), + lastUpdated: now.toISOString(), + subscriptionCanceled: 'true', + willCancelAt: stripeSubscription?.current_period_end + ? new Date(stripeSubscription.current_period_end * 1000).toISOString() + : now.toISOString() + }) + + console.log(`Subscription scheduled for cancellation at period end for user ${userId}`) } - await redis.hset(KEYS.USER_SUBSCRIPTION(userId), updatedSubscriptionData) - - // Remove any scheduled credit resets since user cancelled - await redis.hset(KEYS.USER_CREDITS(userId), { - resetDate: '', // No future resets for cancelled users - lastUpdate: now.toISOString(), - total: '0', // Explicitly ensure total is 0 - used: '0', // Reset used to 0 as well - }) - - console.log(`Trial canceled for user ${userId}, access removed immediately`) return NextResponse.json({ success: true, - message: subscriptionData.status === 'trialing' + message: isTrial ? 'Trial canceled successfully. Your subscription was canceled and access has been removed.' : 'Subscription scheduled for cancellation at the end of the current billing period.', - subscription: updatedSubscriptionData, - credits: { + subscription: subscriptionData, + credits: isTrial ? { total: 0, used: 0, remaining: 0 + } : { + total: parseInt(subscriptionData.total as string || '0'), + 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' + stripeStatus: stripeSubscription?.status || 'unknown', + willCancelAt: !isTrial && stripeSubscription?.current_period_end + ? new Date(stripeSubscription.current_period_end * 1000).toISOString() + : undefined }) } catch (error: any) { diff --git a/auto-analyst-frontend/app/api/trial/start/route.ts b/auto-analyst-frontend/app/api/trial/start/route.ts index a85ed89a..742f401d 100644 --- a/auto-analyst-frontend/app/api/trial/start/route.ts +++ b/auto-analyst-frontend/app/api/trial/start/route.ts @@ -1,10 +1,18 @@ import { NextRequest, NextResponse } from 'next/server' import { getToken } from 'next-auth/jwt' +import Stripe from 'stripe' import redis, { creditUtils, KEYS } from '@/lib/redis' import { CreditConfig, TrialUtils } from '@/lib/credits-config' export const dynamic = 'force-dynamic' +// Initialize Stripe +const stripe = process.env.STRIPE_SECRET_KEY + ? new Stripe(process.env.STRIPE_SECRET_KEY, { + apiVersion: '2025-02-24.acacia', + }) + : null + export async function POST(request: NextRequest) { try { // Get the user token @@ -13,6 +21,10 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } + if (!stripe) { + return NextResponse.json({ error: 'Stripe configuration error' }, { status: 500 }) + } + const userId = token.sub const body = await request.json() const { subscriptionId, planName, interval, amount } = body @@ -21,6 +33,91 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: 'Subscription ID and plan name are required' }, { status: 400 }) } + // CRITICAL: Verify subscription status in Stripe before granting trial access + try { + const subscription = await stripe.subscriptions.retrieve(subscriptionId) + + // Check if subscription is valid and active/trialing + if (!subscription || !['trialing', 'active'].includes(subscription.status)) { + console.log(`Trial access denied for user ${userId}: subscription status is ${subscription?.status || 'not found'}`) + return NextResponse.json({ + error: 'Subscription is not active or trialing. Trial access denied.', + subscriptionStatus: subscription?.status || 'not found' + }, { status: 400 }) + } + + // For trialing subscriptions, verify payment method is attached (more lenient for test mode) + if (subscription.status === 'trialing') { + // Check if subscription has a default payment method + const hasSubscriptionPaymentMethod = subscription.default_payment_method != null + + // Check customer's default payment method as fallback + let hasCustomerPaymentMethod = false + if (!hasSubscriptionPaymentMethod) { + const customer = await stripe.customers.retrieve(subscription.customer as string) + if (typeof customer !== 'string' && !customer.deleted) { + hasCustomerPaymentMethod = !!( + customer.default_source || + customer.invoice_settings?.default_payment_method + ) + } + } + + // Check for successful setup intents as final fallback + let hasSuccessfulSetup = false + if (!hasSubscriptionPaymentMethod && !hasCustomerPaymentMethod) { + const setupIntents = await stripe.setupIntents.list({ + customer: subscription.customer as string, + limit: 5, + }) + + hasSuccessfulSetup = setupIntents.data.some(si => + si.status === 'succeeded' && + (si.metadata?.subscription_id === subscriptionId || si.metadata?.is_trial_setup === 'true') + ) + } + + // Check if we're in test mode for more lenient validation + const isTestMode = process.env.STRIPE_SECRET_KEY?.includes('sk_test_') || false + const allowTestModeTrials = isTestMode && subscription.status === 'trialing' + + // Allow trial if any payment method is found OR if in test mode with valid trialing subscription + if (!hasSubscriptionPaymentMethod && !hasCustomerPaymentMethod && !hasSuccessfulSetup && !allowTestModeTrials) { + console.log(`Trial access denied for user ${userId}: no payment method found`) + console.log(`Subscription payment method: ${hasSubscriptionPaymentMethod}`) + console.log(`Customer payment method: ${hasCustomerPaymentMethod}`) + console.log(`Setup intent: ${hasSuccessfulSetup}`) + console.log(`Test mode allowed: ${allowTestModeTrials}`) + + return NextResponse.json({ + error: 'Payment method setup required. Please complete payment method verification.', + requiresSetup: true, + debug: { + subscriptionPaymentMethod: hasSubscriptionPaymentMethod, + customerPaymentMethod: hasCustomerPaymentMethod, + setupIntentSuccess: hasSuccessfulSetup, + testModeAllowed: allowTestModeTrials, + isTestMode: isTestMode + } + }, { status: 400 }) + } + + if (allowTestModeTrials && !hasSubscriptionPaymentMethod && !hasCustomerPaymentMethod && !hasSuccessfulSetup) { + console.log(`Allowing trial for user ${userId} in test mode despite no payment method found`) + } else { + console.log(`Payment method verified for user ${userId}`) + } + } + + console.log(`Subscription verified for user ${userId}: status=${subscription.status}`) + } catch (stripeError: any) { + console.error('Error verifying subscription:', stripeError) + return NextResponse.json({ + error: 'Unable to verify subscription status. Please try again.', + stripeError: stripeError.message + }, { status: 500 }) + } + const now = new Date() const trialEndDate = TrialUtils.getTrialEndDate(now) diff --git a/auto-analyst-frontend/app/api/user/data/route.ts b/auto-analyst-frontend/app/api/user/data/route.ts index 778f469c..8a8c6f72 100644 --- a/auto-analyst-frontend/app/api/user/data/route.ts +++ b/auto-analyst-frontend/app/api/user/data/route.ts @@ -115,6 +115,7 @@ export async function GET(request: NextRequest) { plan: planCredits.displayName, planType: planCredits.type, status: status, + displayStatus: subscriptionData.displayStatus as string || status, amount: parseFloat(subscriptionData.amount as string) || amount, interval: subscriptionData.interval || interval, renewalDate: renewalDate, diff --git a/auto-analyst-frontend/app/api/verify-payment/route.ts b/auto-analyst-frontend/app/api/verify-payment/route.ts deleted file mode 100644 index 751fc6d3..00000000 --- a/auto-analyst-frontend/app/api/verify-payment/route.ts +++ /dev/null @@ -1,418 +0,0 @@ -import { NextResponse, NextRequest } from 'next/server' -import { getToken } from 'next-auth/jwt' -import Stripe from 'stripe' -import redis, { KEYS } from '@/lib/redis' -import { sendSubscriptionConfirmation, sendPaymentConfirmationEmail } from '@/lib/email' -import logger from '@/lib/utils/logger' -import { CreditConfig } from '@/lib/credits-config' - -export const dynamic = 'force-dynamic' - -// Initialize Stripe only if the secret key exists -const stripe = process.env.STRIPE_SECRET_KEY - ? new Stripe(process.env.STRIPE_SECRET_KEY, { - apiVersion: '2025-02-24.acacia', - }) - : null - -export async function POST(request: NextRequest) { - try { - // Check if Stripe is initialized - if (!stripe) { - console.error('Stripe is not initialized - missing API key') - return NextResponse.json({ error: 'Stripe configuration error' }, { status: 500 }) - } - - // Get auth token from the request - const token = await getToken({ req: request as any }) - if (!token?.sub) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - // Parse request body - const { payment_intent } = await request.json() - if (!payment_intent) { - return NextResponse.json({ error: 'Payment intent ID is required' }, { status: 400 }) - } - - const userId = token.sub - - // Check if this payment has already been processed - const processedKey = `processed_payment:${payment_intent}` - const isProcessed = await redis.get(processedKey) - - if (isProcessed) { - // logger.log(`Payment ${payment_intent} already processed, skipping`) - return NextResponse.json({ success: true, alreadyProcessed: true }) - } - - // Log the payment intent for debugging - // logger.log(`Verifying payment intent: ${payment_intent}`) - - // First get the payment intent directly to verify its status - const intent = await stripe.paymentIntents.retrieve(payment_intent) - // logger.log(`Payment intent status: ${intent.status}`) - - if (intent.status !== 'succeeded') { - return NextResponse.json( - { error: `Payment not successful. Status: ${intent.status}` }, - { status: 400 } - ) - } - - // DIRECT METHOD: Since sessions list might be unreliable, - // manually create the subscription data from the payment intent - const _userEmail = token.email - - // Get metadata from the payment intent if available - const metadata = intent.metadata || {} - // logger.log('Payment intent metadata:', metadata) - - // Get the customer ID from the payment intent - const customerId = intent.customer as string - // logger.log(`Customer ID from payment intent: ${customerId}`) - - // Try to get the product ID from the metadata or use a fallback approach - let productId = metadata.product_id - let priceId = metadata.price_id - - // If no product ID in metadata, try to get it from the line items - if (!productId || !priceId) { - try { - // Try retrieving the session from the payment intent (first approach) - // logger.log(`Attempting to find session by payment intent ${payment_intent}`) - const sessions = await stripe.checkout.sessions.list({ - payment_intent: payment_intent, - limit: 1, - }) - - if (sessions.data.length > 0) { - // logger.log(`Found session: ${sessions.data[0].id}`) - const session = sessions.data[0] - - // If we have a session, update subscription from it - const updateResult = await updateUserSubscriptionFromSession(userId, session) - // logger.log(`Session-based update result: ${updateResult ? 'success' : 'failed'}`) - - // Mark payment as processed - await redis.set(processedKey, 'true') - - // Get plan name from session or line items - let planName = 'Basic'; - if (session.metadata?.plan) { - planName = session.metadata.plan; - } else if (session.line_items) { - try { - const lineItems = await stripe.checkout.sessions.listLineItems(session.id); - if (lineItems.data.length > 0 && lineItems.data[0].price?.product) { - const product = await stripe.products.retrieve(lineItems.data[0].price.product as string); - planName = product.name; - } - } catch (err) { - // logger.log('Could not retrieve product details:', err); - } - } - - // Send email confirmation - if (session && session.customer_details?.email) { - try { - await sendPaymentConfirmationEmail({ - email: session.customer_details.email, - name: session.customer_details.name || token.name || '', - plan: planName, - amount: ((session.amount_total || 0) / 100).toFixed(2), - billingCycle: session.metadata?.cycle === 'yearly' ? 'Annual' : 'Monthly', - date: new Date().toLocaleString('en-US', { - year: 'numeric', - month: 'long', - day: 'numeric' - }) - }); - - // logger.log(`Payment confirmation email sent to ${session.customer_details.email}`); - } catch (emailError) { - // Log the error but don't fail the request - // console.error('Failed to send payment confirmation email:', emailError); - } - } - - return NextResponse.json({ success: true }) - } else { - // logger.log('No session found by payment intent, using fallback method') - } - } catch (err) { - // console.error('Error finding session:', err) - // logger.log('Using fallback method') - } - - // FALLBACK: If no session found, use hardcoded values based on amount - const amount = intent.amount / 100 // Convert from cents to dollars - // logger.log(`Payment amount: ${amount}`) - - // Determine the plan based on the amount - let planName = 'Free Plan' - let planType = 'FREE' - let creditAmount = CreditConfig.getCreditsForPlan('Free').total - let interval = 'month' - - // Updated price ranges to match actual pricing in pricing.tsx - if (amount === 0.75) { - // Daily Standard plan ($5/day) - planName = 'Standard Plan (Daily)' - planType = 'STANDARD' - creditAmount = CreditConfig.getCreditsForPlan('Standard').total - interval = 'day' - } else if (amount >= 10 && amount < 25) { - // Standard monthly plan ($15/month) - planName = 'Standard Plan' - planType = 'STANDARD' - creditAmount = CreditConfig.getCreditsForPlan('Standard').total - } else if (amount >= 25 && amount < 100) { - // Pro monthly plan ($29/month) - planName = 'Pro Plan' - planType = 'PRO' - creditAmount = CreditConfig.getCreditsForPlan('Pro').total // Unlimited - } else if (amount >= 100 && amount < 200) { - // Standard yearly plan ($126/year) - planName = 'Standard Plan (Yearly)' - planType = 'STANDARD' - creditAmount = CreditConfig.getCreditsForPlan('Standard').total - interval = 'year' - } else if (amount >= 200 && amount < 500) { - // Pro yearly plan ($243.60/year) - planName = 'Pro Plan (Yearly)' - planType = 'PRO' - creditAmount = CreditConfig.getCreditsForPlan('Pro').total // Unlimited - interval = 'year' - } - - // Don't determine interval based on amount anymore - it's set above - // logger.log(`Determined plan: ${planName} (${interval}) with ${creditAmount} credits`) - - // Create subscription data - const now = new Date() - let renewalDate = new Date() - if (interval === 'month') { - renewalDate.setMonth(now.getMonth() + 1) - } else if (interval === 'year') { - renewalDate.setFullYear(now.getFullYear() + 1) - } else if (interval === 'day') { - renewalDate.setDate(now.getDate() + 1) - } - - // Save subscription data to Redis - const subscriptionData = { - plan: planName, - planType: planType, - status: 'active', - amount: amount.toString(), - interval: interval, - purchaseDate: now.toISOString(), - renewalDate: renewalDate.toISOString().split('T')[0], - lastUpdated: now.toISOString(), - stripeCustomerId: customerId || 'unknown', - stripeSubscriptionId: intent.id || 'unknown' - } - - // logger.log('Storing fallback subscription data:', subscriptionData) - await redis.hset(KEYS.USER_SUBSCRIPTION(userId), subscriptionData) - - // Calculate next credits reset date (using centralized function) - let creditResetDate: string; - - // Adjust reset date based on interval - if (interval === 'day') { - // For daily plans, reset daily (tomorrow) - const tomorrow = new Date(now); - tomorrow.setDate(tomorrow.getDate() + 1); - creditResetDate = tomorrow.toISOString().split('T')[0]; - } else if (interval === 'year') { - // For yearly plans, use yearly reset - creditResetDate = CreditConfig.getNextYearlyResetDate(); - } else { - // For monthly plans, use monthly reset - creditResetDate = CreditConfig.getNextResetDate(); - } - - // Update credits - const creditData = { - total: creditAmount.toString(), - used: '0', - resetDate: creditResetDate, - lastUpdate: now.toISOString() - } - - // logger.log('Storing credit data:', creditData) - await redis.hset(KEYS.USER_CREDITS(userId), creditData) - - // Get user email (first try from session, then from token) - - const userEmail= _userEmail || '' - if (userEmail && userEmail.includes('@')) { - await sendSubscriptionConfirmation( - userEmail, - planName, - planType, - amount, - interval, - renewalDate.toISOString().split('T')[0], - creditAmount, - creditResetDate - ) - } else { - // logger.log('No email found for user, cannot send confirmation email') - } - } - - // Mark this payment as processed - await redis.set(processedKey, 'true') - - // logger.log(`Successfully processed payment ${payment_intent}`) - return NextResponse.json({ success: true }) - } catch (error: any) { - // console.error('Error verifying payment:', error) - return NextResponse.json( - { error: error.message || 'Failed to verify payment' }, - { status: 500 } - ) - } -} - -// Helper function to update subscription data from a checkout session -async function updateUserSubscriptionFromSession(userId: string, session: Stripe.Checkout.Session) { - try { - // logger.log(`Processing subscription update for user ${userId} from session ${session.id}`) - - // Get line items to extract product details - const lineItems = await stripe!.checkout.sessions.listLineItems(session.id) - if (!lineItems.data.length) { - // console.error('No line items found in checkout session') - return false - } - - // Get the price ID - const priceId = lineItems.data[0].price?.id - if (!priceId) { - // console.error('No price ID found in line item') - return false - } - - // Get the price object which contains the product ID and interval - const price = await stripe!.prices.retrieve(priceId) - const product = await stripe!.products.retrieve(price.product as string) - - // Extract details - const planName = product.name - // logger.log(`Plan name from Stripe: ${planName}`) - const interval = price.recurring?.interval || 'month' - const amount = price.unit_amount! / 100 // Convert from cents - - // Extract metadata if available (can be useful for additional plan data) - const metadata = product.metadata || {} - // logger.log('Product metadata:', metadata) - - // Calculate next renewal date - const now = new Date() - let renewalDate = new Date() - if (interval === 'month') { - renewalDate.setMonth(now.getMonth() + 1) - } else if (interval === 'year') { - renewalDate.setFullYear(now.getFullYear() + 1) - } else if (interval === 'day') { - renewalDate.setDate(now.getDate() + 1) - } - - // Determine plan type and credits allocation with more robust matching - let planType = 'FREE' - let creditAmount = CreditConfig.getCreditsForPlan('Free').total - - // More robust plan name matching - const planNameUpper = planName.toUpperCase() - - // First check for PRO plans (check first to avoid "STANDARD" matching in "PRO STANDARD") - if (planNameUpper.includes('PRO')) { - planType = 'PRO' - creditAmount = CreditConfig.getCreditsForPlan('Pro').total // Unlimited - } else if (planNameUpper.includes('STANDARD')) { - planType = 'STANDARD' - // Check if it's daily billing - if (interval === 'day') { - creditAmount = CreditConfig.getCreditsForPlan('Standard').total // Daily credits - } else { - creditAmount = CreditConfig.getCreditsForPlan('Standard').total // Regular Standard plan credits - } - } - - // logger.log(`Mapped plan type: ${planType} with ${creditAmount} credits`) - - // Create complete subscription data - const subscriptionData = { - plan: planName, - planType: planType, - status: 'active', - amount: amount.toString(), - interval: interval, - purchaseDate: now.toISOString(), - renewalDate: renewalDate.toISOString().split('T')[0], - lastUpdated: now.toISOString(), - stripeCustomerId: session.customer || '', - stripeSubscriptionId: session.subscription || '' - } - - // logger.log('Storing subscription data in Redis:', subscriptionData) - - // Update user's subscription in Redis - await redis.hset(KEYS.USER_SUBSCRIPTION(userId), subscriptionData) - - // Calculate next credits reset date (using centralized function) - let creditResetDate: string; - - // Adjust reset date based on interval - if (interval === 'day') { - // For daily plans, reset daily (tomorrow) - const tomorrow = new Date(now); - tomorrow.setDate(tomorrow.getDate() + 1); - creditResetDate = tomorrow.toISOString().split('T')[0]; - } else if (interval === 'year') { - // For yearly plans, use yearly reset - creditResetDate = CreditConfig.getNextYearlyResetDate(); - } else { - // For monthly plans, use monthly reset - creditResetDate = CreditConfig.getNextResetDate(); - } - - // Update credits - const creditData = { - total: creditAmount.toString(), - used: '0', - resetDate: creditResetDate, - lastUpdate: now.toISOString() - } - - // logger.log('Storing credit data in Redis:', creditData) - await redis.hset(KEYS.USER_CREDITS(userId), creditData) - - // Get user email (first try from session, then from token) - const userEmail = session.customer_email || '' - if (userEmail) { - await sendSubscriptionConfirmation( - userEmail, - planName, - planType, - amount, - interval, - renewalDate.toISOString().split('T')[0], - creditAmount, - creditResetDate - ) - } else { - logger.log('No email found for user, cannot send confirmation email') - } - - // logger.log('Successfully updated user subscription and credits') - return true - } catch (error) { - console.error('Error updating subscription from session:', error) - return false - } -} \ No newline at end of file diff --git a/auto-analyst-frontend/app/api/webhooks/route.ts b/auto-analyst-frontend/app/api/webhooks/route.ts index e83cc429..7cad325e 100644 --- a/auto-analyst-frontend/app/api/webhooks/route.ts +++ b/auto-analyst-frontend/app/api/webhooks/route.ts @@ -165,163 +165,64 @@ export async function POST(request: NextRequest) { // Handle different event types switch (event.type) { case 'checkout.session.completed': { + // All checkouts now use trial subscriptions, handled by trial/start endpoint + // This webhook is kept for logging purposes only const session = event.data.object as Stripe.Checkout.Session + console.log(`Checkout session completed: ${session.id} - handled by trial flow`) + return NextResponse.json({ received: true }) + } - // Extract the user ID from metadata - const userId = session.metadata?.userId - - if (!userId) { - console.error('No user ID found in session metadata') - return NextResponse.json({ error: 'User ID missing from session metadata' }, { status: 400 }) + case 'customer.subscription.updated': { + const subscription = event.data.object as Stripe.Subscription + console.log(`Subscription updated: ${subscription.id}, status: ${subscription.status}`) + + const customerId = subscription.customer as string + if (!customerId) { + console.error('No customer ID found in subscription') + return NextResponse.json({ error: 'No customer ID found' }, { status: 400 }) } - - // Update the user's subscription - const updated = await updateUserSubscription(userId, session) - if (!updated) { - console.error('Failed to update user subscription') - return NextResponse.json({ error: 'Failed to update subscription' }, { status: 500 }) + const userKey = await redis.get(`stripe:customer:${customerId}`) + if (!userKey) { + console.error(`No user found for Stripe customer ${customerId}`) + return NextResponse.json({ received: true }) } - // Get customer details and subscription info - const customerEmail = session.customer_email || session.customer_details?.email; - const customerName = session.customer_details?.name || ''; - - // Get metadata from the session - const plan = session.metadata?.plan || 'Standard'; - const cycle = session.metadata?.cycle || 'monthly'; - - // Format amount - const amount = ((session.amount_total || 0) / 100).toFixed(2); - - // Format date - const date = new Date().toLocaleString('en-US', { - year: 'numeric', - month: 'long', - day: 'numeric' - }); - - // Send confirmation email - if (customerEmail) { - try { - await sendPaymentConfirmationEmail({ - email: customerEmail, - name: customerName, - plan: plan, - amount: amount, - billingCycle: cycle === 'yearly' ? 'Annual' : 'Monthly', - date: date - }); - // logger.log(`Payment confirmation email sent to ${customerEmail}`); - } catch (error) { - console.error('Failed to send payment confirmation email:', error); - // Continue processing - don't fail the webhook due to email issues - } + const userId = userKey.toString() + + // Get current subscription data from Redis + const currentSubscriptionData = await redis.hgetall(KEYS.USER_SUBSCRIPTION(userId)) + const currentStatus = currentSubscriptionData?.status + + console.log(`Subscription status change for user ${userId}: ${currentStatus} -> ${subscription.status}`) + + // Always sync the status with Stripe, but handle special cases + const updateData: any = { + status: subscription.status, + lastUpdated: new Date().toISOString(), + stripeSubscriptionStatus: subscription.status } - return NextResponse.json({ received: true }) - } + // Handle specific status transitions + if (currentStatus === 'trialing' && subscription.status === 'active') { + console.log(`Trial to active transition detected for user ${userId}`) + updateData.trialEndedAt = new Date().toISOString() + updateData.trialToActiveDate = new Date().toISOString() + } - case 'customer.subscription.updated': { - const subscription = event.data.object as Stripe.Subscription - // Handle subscription update logic - // logger.log('Subscription updated event received:', subscription.id) + if (subscription.status === 'canceled') { + updateData.canceledAt = new Date().toISOString() + } - // Check if the subscription status has changed to canceled or unpaid - if (subscription.status === 'canceled' || subscription.status === 'unpaid') { - // Get the customer ID from the subscription - const customerId = subscription.customer as string - if (!customerId) { - console.error('No customer ID found in subscription') - return NextResponse.json({ error: 'No customer ID found' }, { status: 400 }) - } - - // Look up the user by customer ID in our database - const userKey = await redis.get(`stripe:customer:${customerId}`) - if (!userKey) { - console.error(`No user found for Stripe customer ${customerId}`) - return NextResponse.json({ received: true }) - } - - const userId = userKey.toString() - // logger.log(`Found user ${userId} for Stripe customer ${customerId}`) - - // Get current subscription data - const subscriptionData = await redis.hgetall(KEYS.USER_SUBSCRIPTION(userId)) - - // If the subscription is canceled, mark it as such in our database - if (subscription.status === 'canceled') { - // Update subscription status to indicate it's being canceled - await redis.hset(KEYS.USER_SUBSCRIPTION(userId), { - status: 'canceling', - lastUpdated: new Date().toISOString(), - // Add flags to ensure next month they only get free tier credits - pendingDowngrade: 'true', - nextPlanType: 'FREE' - }) - - // Mark the credits to be downgraded on next reset - await redis.hset(KEYS.USER_CREDITS(userId), { - nextTotalCredits: CreditConfig.getCreditsForPlan('Free').total.toString(), - pendingDowngrade: 'true', - lastUpdate: new Date().toISOString() - }) - - // logger.log(`Updated subscription status to canceling for user ${userId}`) - } else if (subscription.status === 'unpaid') { - // Handle unpaid subscriptions by marking them as inactive - await redis.hset(KEYS.USER_SUBSCRIPTION(userId), { - status: 'inactive', - lastUpdated: new Date().toISOString(), - // Add flags to ensure next reset they only get free tier credits - pendingDowngrade: 'true', - nextPlanType: 'FREE' - }) - - // Mark the credits to be downgraded on next reset - await redis.hset(KEYS.USER_CREDITS(userId), { - nextTotalCredits: CreditConfig.getCreditsForPlan('Free').total.toString(), - pendingDowngrade: 'true', - lastUpdate: new Date().toISOString() - }) - - // logger.log(`Updated subscription status to inactive for user ${userId} due to unpaid status`) - } - } - // Check if the subscription has changed plans - else if (subscription.items && subscription.items.data.length > 0) { - // Get the customer ID from the subscription - const customerId = subscription.customer as string - - // Look up the user by customer ID in our database - const userKey = await redis.get(`stripe:customer:${customerId}`) - if (!userKey) { - // logger.log(`No user found for Stripe customer ${customerId}`) - return NextResponse.json({ received: true }) - } - - const userId = userKey.toString() - - // Get the price ID from the subscription item - const priceId = subscription.items.data[0].price.id - - // Fetch price and product details to determine the plan - const price = await stripe.prices.retrieve(priceId) - const product = await stripe.products.retrieve(price.product as string) - - // Update subscription with new plan details - this will be used at next credit reset - await updateUserSubscription(userId, { - id: subscription.id, - customer: customerId, - customer_email: '', - // Add metadata to help with plan identification - metadata: { - userId: userId, - planName: product.name - } - } as any) + if (subscription.status === 'unpaid' || subscription.status === 'past_due') { + updateData.unpaidAt = new Date().toISOString() } + // Update subscription data + await redis.hset(KEYS.USER_SUBSCRIPTION(userId), updateData) + + console.log(`Updated subscription status to ${subscription.status} for user ${userId}`) + return NextResponse.json({ received: true }) } @@ -354,41 +255,33 @@ export async function POST(request: NextRequest) { return NextResponse.json({ received: true }) } - // Downgrade to Free plan + // Handle subscription cancellation - set credits to 0 (no free plan anymore) const now = new Date() - // Calculate next reset date (1 month from now) - const resetDate = new Date(now) - resetDate.setMonth(resetDate.getMonth() + 1) - - // Get Free plan configuration - const freeCredits = CreditConfig.getCreditsForPlan('Free') + // Set credits to 0 immediately when subscription is canceled + await creditUtils.setZeroCredits(userId) - // Update subscription data + // Update subscription data to reflect cancellation await redis.hset(KEYS.USER_SUBSCRIPTION(userId), { - plan: freeCredits.displayName, - planType: freeCredits.type, - status: 'active', // Free plan is always active + plan: 'No Active Plan', + planType: 'NONE', + status: 'canceled', amount: '0', interval: 'month', lastUpdated: now.toISOString(), + canceledAt: now.toISOString(), // Clear Stripe IDs as they're no longer valid stripeCustomerId: '', stripeSubscriptionId: '' }) - // Get current used credits to preserve them - const currentCredits = await redis.hgetall(KEYS.USER_CREDITS(userId)) - const usedCredits = currentCredits && currentCredits.used - ? parseInt(currentCredits.used as string) - : 0 - - // Set credits to Free plan level using centralized config, but preserve used credits + // Remove any scheduled credit resets since user has no plan await redis.hset(KEYS.USER_CREDITS(userId), { - total: freeCredits.total.toString(), - used: Math.min(usedCredits, freeCredits.total).toString(), // Used credits shouldn't exceed new total - resetDate: resetDate.toISOString().split('T')[0], - lastUpdate: now.toISOString() + total: '0', + used: '0', + resetDate: '', // No resets for users without subscription + lastUpdate: now.toISOString(), + subscriptionDeleted: 'true' // Mark this as a genuine subscription deletion }) // logger.log(`User ${userId} downgraded to Free plan after subscription cancellation`) @@ -424,9 +317,10 @@ export async function POST(request: NextRequest) { case 'invoice.payment_succeeded': { const invoice = event.data.object as Stripe.Invoice + console.log(`Invoice payment succeeded: ${invoice.id}, billing_reason: ${invoice.billing_reason}`) - // Check if this is for a subscription that just ended its trial - if (invoice.subscription && invoice.billing_reason === 'subscription_cycle') { + // Check if this is for a subscription payment + if (invoice.subscription) { const subscription = await stripe.subscriptions.retrieve(invoice.subscription as string) // Get customer and user info @@ -435,15 +329,48 @@ export async function POST(request: NextRequest) { if (userKey) { const userId = userKey.toString() + console.log(`Processing payment success for user ${userId}, subscription status: ${subscription.status}`) - // Update subscription status from trialing to active - await redis.hset(KEYS.USER_SUBSCRIPTION(userId), { - status: 'active', - lastUpdated: new Date().toISOString(), - trialEndedAt: new Date().toISOString() - }) + // Get current subscription data from Redis + const currentSubscriptionData = await redis.hgetall(KEYS.USER_SUBSCRIPTION(userId)) - // logger.log(`User ${userId} trial ended successfully, subscription is now active`) + // Handle different payment scenarios + if (invoice.billing_reason === 'subscription_cycle') { + // This is a regular billing cycle payment (trial ended or monthly renewal) + console.log(`Subscription cycle payment for user ${userId} - updating status to active`) + + await redis.hset(KEYS.USER_SUBSCRIPTION(userId), { + status: 'active', + lastUpdated: new Date().toISOString(), + trialEndedAt: new Date().toISOString(), + lastPaymentDate: new Date().toISOString(), + stripeSubscriptionStatus: subscription.status // Keep Stripe status in sync + }) + + console.log(`User ${userId} trial ended successfully, subscription is now active`) + } else if (invoice.billing_reason === 'subscription_create') { + // This is the initial subscription creation payment (if any) + console.log(`Initial subscription payment for user ${userId}`) + + await redis.hset(KEYS.USER_SUBSCRIPTION(userId), { + status: subscription.status, // Use Stripe's status + lastUpdated: new Date().toISOString(), + initialPaymentDate: new Date().toISOString(), + stripeSubscriptionStatus: subscription.status + }) + } else { + // Other billing reasons (proration, etc.) + console.log(`Other payment type for user ${userId}: ${invoice.billing_reason}`) + + await redis.hset(KEYS.USER_SUBSCRIPTION(userId), { + status: subscription.status, // Always sync with Stripe status + lastUpdated: new Date().toISOString(), + lastPaymentDate: new Date().toISOString(), + stripeSubscriptionStatus: subscription.status + }) + } + } else { + console.log(`No user found for Stripe customer ${customerId}`) } } @@ -480,6 +407,125 @@ export async function POST(request: NextRequest) { return NextResponse.json({ received: true }) } + case 'payment_intent.payment_failed': { + const paymentIntent = event.data.object as Stripe.PaymentIntent + console.log(`Payment intent failed: ${paymentIntent.id}`) + + // Check if this is a trial payment intent by looking at metadata + if (paymentIntent.metadata?.isTrial === 'true') { + const userId = paymentIntent.metadata?.userId + + if (userId) { + console.log(`Trial payment authorization failed for user ${userId}`) + + // Prevent trial access by ensuring credits remain at 0 + await creditUtils.setZeroCredits(userId) + + // Mark the attempt as failed in subscription data + await redis.hset(KEYS.USER_SUBSCRIPTION(userId), { + status: 'payment_failed', + lastUpdated: new Date().toISOString(), + paymentFailedAt: new Date().toISOString(), + failureReason: 'Payment authorization failed during trial signup' + }) + + console.log(`Trial access prevented for user ${userId} due to payment authorization failure`) + } + } + + return NextResponse.json({ received: true }) + } + + case 'payment_intent.canceled': { + const paymentIntent = event.data.object as Stripe.PaymentIntent + console.log(`Payment intent canceled: ${paymentIntent.id}`) + + // Check if this is a trial payment intent + if (paymentIntent.metadata?.isTrial === 'true') { + const userId = paymentIntent.metadata?.userId + + if (userId) { + console.log(`Trial payment intent canceled for user ${userId}`) + + // Ensure user doesn't get trial access + await creditUtils.setZeroCredits(userId) + + // Update subscription data to reflect cancellation + await redis.hset(KEYS.USER_SUBSCRIPTION(userId), { + status: 'canceled', + lastUpdated: new Date().toISOString(), + canceledAt: new Date().toISOString(), + cancelReason: 'Payment intent canceled during trial signup' + }) + + console.log(`Trial access prevented for user ${userId} due to payment intent cancellation`) + } + } + + return NextResponse.json({ received: true }) + } + + case 'setup_intent.setup_failed': { + const setupIntent = event.data.object as Stripe.SetupIntent + console.log(`Setup intent failed: ${setupIntent.id}`) + + // Check if this is a trial setup intent + if (setupIntent.metadata?.is_trial_setup === 'true') { + const subscriptionId = setupIntent.metadata?.subscription_id + + if (subscriptionId) { + // Get the subscription to find the customer + try { + const subscription = await stripe.subscriptions.retrieve(subscriptionId) + const customerId = subscription.customer as string + const userKey = await redis.get(`stripe:customer:${customerId}`) + + if (userKey) { + const userId = userKey.toString() + console.log(`Trial setup failed for user ${userId}`) + + // Cancel the trial subscription since setup failed + await stripe.subscriptions.cancel(subscriptionId) + + // Ensure user doesn't get trial access + await creditUtils.setZeroCredits(userId) + + // Update subscription data + await redis.hset(KEYS.USER_SUBSCRIPTION(userId), { + status: 'setup_failed', + lastUpdated: new Date().toISOString(), + setupFailedAt: new Date().toISOString(), + failureReason: 'Payment method setup failed during trial signup' + }) + + console.log(`Trial access prevented for user ${userId} due to setup failure`) + } + } catch (error) { + console.error('Error handling setup intent failure:', error) + } + } + } + + return NextResponse.json({ received: true }) + } + + case 'payment_intent.requires_action': { + const paymentIntent = event.data.object as Stripe.PaymentIntent + console.log(`Payment intent requires action (3D Secure): ${paymentIntent.id}`) + + // For trial payment intents, log the authentication requirement + if (paymentIntent.metadata?.isTrial === 'true') { + const userId = paymentIntent.metadata?.userId + console.log(`Trial payment requires 3D Secure authentication for user ${userId}`) + + // Don't grant trial access until authentication is complete + // The payment will either succeed (triggering payment_intent.succeeded) + // or fail (triggering payment_intent.payment_failed) + } + + return NextResponse.json({ received: true }) + } + // Add more event types as needed default: diff --git a/auto-analyst-frontend/app/chat/page.tsx b/auto-analyst-frontend/app/chat/page.tsx index c1bc9ee5..2eb86c58 100644 --- a/auto-analyst-frontend/app/chat/page.tsx +++ b/auto-analyst-frontend/app/chat/page.tsx @@ -1,15 +1,19 @@ "use client" import { useEffect } from "react" +import { useSearchParams } from "next/navigation" import ChatInterface from "@/components/chat/ChatInterface" import ResponsiveLayout from "../../components/ResponsiveLayout" import "../globals.css" import { useFreeTrialStore } from "@/lib/store/freeTrialStore" import { useSession } from "next-auth/react" +import { useCredits } from "@/lib/contexts/credit-context" export default function ChatPage() { const { status } = useSession() const { queriesUsed, hasFreeTrial } = useFreeTrialStore() + const { checkCredits } = useCredits() + const searchParams = useSearchParams() // Check for first-time free trial users useEffect(() => { @@ -20,6 +24,46 @@ export default function ChatPage() { } } }, [status, queriesUsed, hasFreeTrial]) + + // Enhanced credit refresh when navigating from account page + useEffect(() => { + if (status === "authenticated" && searchParams) { + const refreshParam = searchParams.get('refresh') + const fromParam = searchParams.get('from') + + // Refresh credits if: + // 1. Explicit refresh parameter is present + // 2. Coming from account or pricing page + // 3. Recent navigation from account page (check localStorage) + const shouldRefresh = refreshParam || + fromParam === 'account' || + fromParam === 'pricing' || + localStorage.getItem('navigateFromAccount') === 'true' + + if (shouldRefresh && checkCredits) { + console.log('Refreshing credits due to navigation from account/pricing page') + + // Emit event to show loading state in credit display + window.dispatchEvent(new CustomEvent('creditsUpdated')) + + // Small delay to ensure any backend processes have completed + setTimeout(() => { + checkCredits() + }, 500) + + // Clear the navigation flag + localStorage.removeItem('navigateFromAccount') + + // Clean up URL parameters + if (refreshParam || fromParam) { + const url = new URL(window.location.href) + url.searchParams.delete('refresh') + url.searchParams.delete('from') + window.history.replaceState({}, '', url.toString()) + } + } + } + }, [status, searchParams, checkCredits]) return ( diff --git a/auto-analyst-frontend/app/checkout/success/page.tsx b/auto-analyst-frontend/app/checkout/success/page.tsx index 9634f0fd..bf8a5207 100644 --- a/auto-analyst-frontend/app/checkout/success/page.tsx +++ b/auto-analyst-frontend/app/checkout/success/page.tsx @@ -7,6 +7,7 @@ import { useToast } from '@/components/ui/use-toast'; import { Loader2, ShieldAlert, CheckCircle } from 'lucide-react' import Layout from '@/components/layout' import logger from '@/lib/utils/logger' +import { TrialUtils } from '@/lib/credits-config'; export default function CheckoutSuccess() { const router = useRouter() @@ -32,7 +33,7 @@ export default function CheckoutSuccess() { let response, data; if (subscription_id) { - // New subscription-based trial approach + // All checkouts now use trial subscriptions response = await fetch('/api/trial/start', { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -44,35 +45,8 @@ export default function CheckoutSuccess() { timestamp: new Date().getTime() }), }); - } else if (payment_intent) { - // Legacy payment intent approach - check if it's a trial - const stripeResponse = await fetch(`/api/payment-intent-details?payment_intent=${payment_intent}`); - const stripeData = await stripeResponse.json(); - - if (stripeData.metadata?.isTrial === 'true') { - // This is a trial payment - use the trial start endpoint - response = await fetch('/api/trial/start', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - subscriptionId: payment_intent, // Legacy compatibility - planName: stripeData.metadata?.planName || 'Standard', - interval: stripeData.metadata?.interval || 'month', - amount: stripeData.amount ? stripeData.amount / 100 : 15, // Convert from cents - timestamp: new Date().getTime() - }), - }); - } else { - // This is a regular payment - use the verify payment endpoint - response = await fetch('/api/verify-payment', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - payment_intent, - timestamp: new Date().getTime() - }), - }); - } + } else { + throw new Error('No subscription ID found - all payments should now use trial subscriptions'); } if (!response) { @@ -95,7 +69,7 @@ export default function CheckoutSuccess() { const isTrialStart = subscription_id || data.subscription?.status === 'trialing'; toast({ title: isTrialStart ? 'Trial Started!' : 'Subscription Activated!', - description: isTrialStart ? 'Your 7-day trial has started with full access.' : 'Your plan has been successfully activated.', + description: isTrialStart ? `Your ${TrialUtils.getTrialDisplayText()} has started with full access.` : 'Your plan has been successfully activated.', duration: 4000 }); diff --git a/auto-analyst-frontend/components/CheckoutForm.tsx b/auto-analyst-frontend/components/CheckoutForm.tsx index 38090f29..195e56e0 100644 --- a/auto-analyst-frontend/components/CheckoutForm.tsx +++ b/auto-analyst-frontend/components/CheckoutForm.tsx @@ -69,28 +69,28 @@ export default function CheckoutForm({ planName, amount, interval, clientSecret, } } else { // For regular payments, use confirmPayment - const { error: submitError, paymentIntent } = await stripe.confirmPayment({ - elements, - confirmParams: { - return_url: `${window.location.origin}/checkout/success`, - }, - redirect: 'if_required', - }) + const { error: submitError, paymentIntent } = await stripe.confirmPayment({ + elements, + confirmParams: { + return_url: `${window.location.origin}/checkout/success`, + }, + redirect: 'if_required', + }) - setProcessing(false) + setProcessing(false) - if (submitError) { - setError(submitError.message || 'An error occurred when processing your payment') + if (submitError) { + setError(submitError.message || 'An error occurred when processing your payment') } else if (paymentIntent && (paymentIntent.status === 'succeeded' || paymentIntent.status === 'requires_capture')) { // For manual capture (trial), status will be 'requires_capture' // For normal payments, status will be 'succeeded' - setError(null) - setSucceeded(true) - - // Show success animation for a second before redirecting - setTimeout(() => { - router.push(`/checkout/success?payment_intent=${paymentIntent.id}`) - }, 1500) + setError(null) + setSucceeded(true) + + // Show success animation for a second before redirecting + setTimeout(() => { + router.push(`/checkout/success?payment_intent=${paymentIntent.id}`) + }, 1500) } } } diff --git a/auto-analyst-frontend/components/chat/ChatInput.tsx b/auto-analyst-frontend/components/chat/ChatInput.tsx index 167c80e8..86dd838f 100644 --- a/auto-analyst-frontend/components/chat/ChatInput.tsx +++ b/auto-analyst-frontend/components/chat/ChatInput.tsx @@ -256,19 +256,24 @@ const ChatInput = forwardRef< checkDisabledStatus(); }, []); - // Add credit refresh on navigation from accounts page + // Enhanced credit refresh on navigation from accounts page useEffect(() => { // Listen for focus events to detect when user returns from accounts page const handleWindowFocus = () => { - // Check if we just came from accounts page by checking document referrer + // Check navigation flags and referrer + const navigationFlag = localStorage.getItem('navigateFromAccount') === 'true' const referrer = document.referrer; const isFromAccountsPage = referrer.includes('/account') || referrer.includes('/pricing'); - if (isFromAccountsPage && session) { + if ((navigationFlag || isFromAccountsPage) && session) { + console.log('Refreshing credits due to navigation from account page (focus event)') // Refresh credits when coming back from accounts/pricing page setTimeout(() => { checkCredits(); - }, 1000); // Small delay to ensure any backend processes have completed + }, 800); // Small delay to ensure any backend processes have completed + + // Clear the navigation flag + localStorage.removeItem('navigateFromAccount') } }; diff --git a/auto-analyst-frontend/components/chat/CreditBalance.tsx b/auto-analyst-frontend/components/chat/CreditBalance.tsx index c23af9c6..d49310f8 100644 --- a/auto-analyst-frontend/components/chat/CreditBalance.tsx +++ b/auto-analyst-frontend/components/chat/CreditBalance.tsx @@ -11,6 +11,7 @@ const CreditBalance = () => { const { remainingCredits, isLoading, checkCredits, creditResetDate } = useCredits() const [isHovering, setIsHovering] = useState(false) const [daysToReset, setDaysToReset] = useState(null) + const [isRefreshing, setIsRefreshing] = useState(false) // Check if credits represent unlimited (Pro plan) const isUnlimited = remainingCredits > 999998; @@ -25,6 +26,21 @@ const CreditBalance = () => { setDaysToReset(diffDays > 0 ? diffDays : null); } }, [creditResetDate]); + + // Enhanced credit refresh detection + useEffect(() => { + // Listen for navigation events that might trigger credit refresh + const handleCreditRefresh = () => { + setIsRefreshing(true) + setTimeout(() => setIsRefreshing(false), 2000) // Show refreshing for 2 seconds + } + + window.addEventListener('creditsUpdated', handleCreditRefresh) + + return () => { + window.removeEventListener('creditsUpdated', handleCreditRefresh) + } + }, []) // Display for credits const creditsDisplay = isUnlimited ? ( @@ -65,7 +81,14 @@ const CreditBalance = () => { transition={{ duration: 0.2 }} > - {isLoading ? '...' : creditsDisplay} + + {isLoading || isRefreshing ? ( + + + ... + + ) : creditsDisplay} + {!isUnlimited && daysToReset && daysToReset <= 7 && ( diff --git a/auto-analyst-frontend/components/layout.tsx b/auto-analyst-frontend/components/layout.tsx index 853e1e67..5aae83aa 100644 --- a/auto-analyst-frontend/components/layout.tsx +++ b/auto-analyst-frontend/components/layout.tsx @@ -40,6 +40,12 @@ export default function Layout({ children }: LayoutProps) { className={`text-gray-600 hover:text-[#FF7F7F] ${ pathname === '/chat' ? 'text-[#FF7F7F] font-medium' : '' }`} + onClick={() => { + // Set flag if coming from account or pricing page + if (pathname === '/account' || pathname === '/pricing') { + localStorage.setItem('navigateFromAccount', 'true') + } + }} > Chat @@ -106,7 +112,13 @@ export default function Layout({ children }: LayoutProps) { className={`text-gray-600 hover:text-[#FF7F7F] py-2 ${ pathname === '/chat' ? 'text-[#FF7F7F] font-medium' : '' }`} - onClick={() => setMobileMenuOpen(false)} + onClick={() => { + setMobileMenuOpen(false) + // Set flag if coming from account or pricing page + if (pathname === '/account' || pathname === '/pricing') { + localStorage.setItem('navigateFromAccount', 'true') + } + }} > Chat diff --git a/auto-analyst-frontend/docs/README.md b/auto-analyst-frontend/docs/README.md new file mode 100644 index 00000000..4a8c5b52 --- /dev/null +++ b/auto-analyst-frontend/docs/README.md @@ -0,0 +1,46 @@ +# Auto-Analyst Documentation + +This documentation covers the payment processing, subscription management, and data storage systems used in Auto-Analyst. + +## Table of Contents + +### Core Systems +- [🔄 Redis Data Schema](./redis-schema.md) - Complete Redis data structure and key patterns +- [💳 Stripe Integration](./stripe-integration.md) - Payment processing and subscription management +- [🎯 Webhooks](./webhooks.md) - Event handling and data synchronization +- [💰 Credit System](./credit-system.md) - Credit management and billing logic + +### API Reference +- [📡 API Endpoints](./api-endpoints.md) - Complete list of working endpoints +- [🔐 Authentication](./authentication.md) - User authentication and session management + +### Workflows +- [🆓 Trial System](./trial-system.md) - 7-day trial implementation with Stripe manual capture +- [❌ Cancellation Flows](./cancellation-flows.md) - Different cancellation scenarios and handling + +## Quick Start + +1. **Environment Setup**: Ensure you have the required environment variables set up (see [Stripe Integration](./stripe-integration.md)) +2. **Redis Configuration**: Review the [Redis Schema](./redis-schema.md) for data structure +3. **Webhook Setup**: Configure Stripe webhooks as described in [Webhooks](./webhooks.md) + +## System Overview + +Auto-Analyst uses a hybrid approach combining: +- **Stripe** for payment processing and subscription management +- **Redis** for fast user data access and caching +- **Webhooks** for real-time synchronization between Stripe and our system +- **Credit-based billing** for usage tracking and limits + +## Key Features + +- ✅ 7-day free trial with payment authorization +- ✅ Automatic trial-to-paid conversion +- ✅ Real-time credit tracking +- ✅ Subscription management (upgrade, downgrade, cancel) +- ✅ Webhook-based data synchronization +- ✅ Comprehensive error handling + +## Support + +For questions about this documentation or the system implementation, please refer to the specific documentation files or contact the development team. \ No newline at end of file diff --git a/auto-analyst-frontend/lib/redis.ts b/auto-analyst-frontend/lib/redis.ts index 0bb8bd1c..717773cb 100644 --- a/auto-analyst-frontend/lib/redis.ts +++ b/auto-analyst-frontend/lib/redis.ts @@ -342,8 +342,9 @@ export const subscriptionUtils = { subscriptionData.status === 'inactive' )) || (creditsData && creditsData.pendingDowngrade === 'true'); - + // Check if subscription is canceled (including trial cancellations) + // IMPORTANT: Only zero out credits if user actually canceled, not during successful transitions const isCanceled = subscriptionData && ( subscriptionData.status === 'canceled' || subscriptionData.status === 'canceling' @@ -352,18 +353,28 @@ export const subscriptionUtils = { // Process active subscriptions, trials, and pending downgrades/cancellations const shouldProcess = subscriptionData && ( subscriptionData.status === 'active' || - subscriptionData.status === 'trialing' || // Fixed: was 'trial' + subscriptionData.status === 'trialing' || subscriptionData.status === 'canceling' || subscriptionData.status === 'inactive' || - subscriptionData.status === 'canceled' || // Added: handle canceled trials isPendingDowngrade ); - // Special handling for canceled/canceling subscriptions - immediately set to 0 credits + // Special handling for canceled/canceling subscriptions + // BUT: Only zero credits if this is a genuine cancellation, not a successful trial conversion if (isCanceled) { - await creditUtils.setZeroCredits(userId); - console.log(`[Credits] Set zero credits for ${subscriptionData.status} user ${userId}`); - return true; + // Check if this is a trial that was explicitly canceled vs. a successful conversion + const wasCanceledDuringTrial = creditsData && creditsData.trialCanceled === 'true'; + const wasSubscriptionDeleted = creditsData && creditsData.subscriptionDeleted === 'true'; + const hasExplicitCancelation = subscriptionData.canceledAt || subscriptionData.subscriptionCanceled === 'true'; + const isGenuineCancellation = wasCanceledDuringTrial || wasSubscriptionDeleted || hasExplicitCancelation; + + if (isGenuineCancellation) { + await creditUtils.setZeroCredits(userId); + console.log(`[Credits] Set zero credits for genuinely canceled user ${userId} (status: ${subscriptionData.status})`); + return true; + } else { + console.log(`[Credits] Skipping credit reset for user ${userId} - appears to be successful trial conversion, not cancellation`); + } } // Treat all plans (including Free) similarly for credit refreshes diff --git a/auto-analyst-frontend/lib/utils/logger.ts b/auto-analyst-frontend/lib/utils/logger.ts index 405abb3a..282b9f0b 100644 --- a/auto-analyst-frontend/lib/utils/logger.ts +++ b/auto-analyst-frontend/lib/utils/logger.ts @@ -6,7 +6,7 @@ */ // Set this to true to disable all non-error logs -const DISABLE_LOGS = true; +const DISABLE_LOGS = false; const logger = { log: (...args: any[]) => { From 7864cc4470f848a020327c037b8e6032ba036441 Mon Sep 17 00:00:00 2001 From: Ashad Qureshi Date: Mon, 23 Jun 2025 20:31:11 +0500 Subject: [PATCH 4/4] Build Fixes - Removed Downgrade Option --- auto-analyst-frontend/app/account/page.tsx | 130 +----------------- .../app/api/user/downgrade-plan/route.ts | 84 ----------- .../app/api/webhooks/route.ts | 30 ++-- auto-analyst-frontend/lib/redis.ts | 4 +- 4 files changed, 20 insertions(+), 228 deletions(-) delete mode 100644 auto-analyst-frontend/app/api/user/downgrade-plan/route.ts diff --git a/auto-analyst-frontend/app/account/page.tsx b/auto-analyst-frontend/app/account/page.tsx index 7ebeb0f0..a06885a3 100644 --- a/auto-analyst-frontend/app/account/page.tsx +++ b/auto-analyst-frontend/app/account/page.tsx @@ -74,9 +74,7 @@ export default function AccountPage() { const { toast } = useToast() const [isRefreshing, setIsRefreshing] = useState(false) const [lastUpdated, setLastUpdated] = useState(new Date()) - const [confirmDowngradeOpen, setConfirmDowngradeOpen] = useState(false) const [confirmCancelOpen, setConfirmCancelOpen] = useState(false) - const [targetPlan, setTargetPlan] = useState('free') // Main heading styles - Updated for consistency const mainHeadingStyle = "text-2xl font-bold text-gray-900 mb-2" @@ -402,76 +400,7 @@ export default function AccountPage() { ); }; - // Add a function to handle plan downgrade - const handlePlanDowngrade = async (targetPlan: string) => { - // Set the target plan and open confirmation dialog - setTargetPlan(targetPlan) - setConfirmDowngradeOpen(true) - } - - // Add function to execute the downgrade after confirmation - const executeDowngrade = async () => { - setIsRefreshing(true) - try { - const response = await fetch('/api/user/downgrade-plan', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ targetPlan }), - }) - - if (!response.ok) { - throw new Error('Failed to downgrade plan') - } - - const result = await response.json() - // logger.log('Plan downgrade result:', result) - - // Refresh user data to reflect the changes - await refreshUserData() - - toast({ - title: 'Plan downgraded successfully', - description: `Your subscription has been changed to ${result.subscription.plan}`, - duration: 3000 - }) - } catch (error) { - console.error('Error downgrading plan:', error) - toast({ - title: 'Failed to downgrade plan', - description: 'Please try again later or contact support', - variant: 'destructive', - duration: 3000 - }) - } finally { - setIsRefreshing(false) - setConfirmDowngradeOpen(false) - } - } - - // Helper to determine if current plan can be downgraded - const canDowngrade = () => { - if (!subscription) return false - const planName = subscription.plan.toLowerCase() - return planName.includes('standard') - } - - // Helper to get the next lower plan using centralized config - const getDowngradePlanName = () => { - if (!subscription) return 'Free Plan' - const currentPlan = CreditConfig.getCreditsForPlan(subscription.plan) - - // Get available plans and find the next lower one - const allPlans = CreditConfig.getAllPlans().sort((a, b) => a.total - b.total) - const currentIndex = allPlans.findIndex(p => p.type === currentPlan.type) - - if (currentIndex > 0) { - return allPlans[currentIndex - 1].displayName - } - - return 'Free Plan' - } + // Add function to handle subscription cancellation const handleCancelSubscription = () => { @@ -902,19 +831,7 @@ export default function AccountPage() { > Update Payment Method */} - {canDowngrade() && ( - - )} +