diff --git a/auto-analyst-frontend/app/account/page.tsx b/auto-analyst-frontend/app/account/page.tsx index a06885a3..9747bfe8 100644 --- a/auto-analyst-frontend/app/account/page.tsx +++ b/auto-analyst-frontend/app/account/page.tsx @@ -653,10 +653,10 @@ export default function AccountPage() {
+ + ) + } + + return ( +
+

Signed in as {session.user?.email}

+ +
+ ) +} +``` + +### Server-Side Session Access + +```typescript +// API Routes +import { getToken } from "next-auth/jwt" + +export async function GET(request: NextRequest) { + const token = await getToken({ req: request }) + + if (!token) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const userId = token.sub + const userEmail = token.email + + // Use user data... +} +``` + +```typescript +// Server Components +import { getServerSession } from "next-auth/next" +import { authOptions } from "@/app/api/auth/[...nextauth]/route" + +export default async function ServerComponent() { + const session = await getServerSession(authOptions) + + if (!session) { + redirect('/login') + } + + return
Welcome {session.user?.name}
+} +``` + +## Session Provider Setup + +### Root Layout Configuration +**File**: `app/layout.tsx` + +```typescript +import { SessionProvider } from "next-auth/react" +import { AuthProvider } from "@/components/AuthProvider" + +export default function RootLayout({ + children, +}: { + children: React.ReactNode +}) { + return ( + + + + {children} + + + + ) +} +``` + +### Auth Provider Component +**File**: `components/AuthProvider.tsx` + +```typescript +'use client' +import { SessionProvider } from "next-auth/react" + +export function AuthProvider({ children }: { children: React.ReactNode }) { + return {children} +} +``` + +## API Route Protection + +### Standard Protection Pattern + +```typescript +// app/api/example/route.ts +import { NextRequest, NextResponse } from 'next/server' +import { getToken } from 'next-auth/jwt' + +export async function GET(request: NextRequest) { + // Authenticate user + const token = await getToken({ req: request }) + + if (!token?.sub) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const userId = token.sub + const userEmail = token.email + + try { + // Your API logic here + return NextResponse.json({ success: true }) + } catch (error) { + return NextResponse.json( + { error: 'Internal server error' }, + { status: 500 } + ) + } +} +``` + +### Reusable Auth Middleware + +```typescript +// lib/auth-middleware.ts +import { NextRequest } from 'next/server' +import { getToken } from 'next-auth/jwt' + +export async function requireAuth(request: NextRequest) { + const token = await getToken({ req: request }) + + if (!token?.sub) { + throw new Error('Unauthorized') + } + + return { + userId: token.sub, + userEmail: token.email, + userName: token.name, + userImage: token.picture + } +} + +// Usage in API routes +export async function GET(request: NextRequest) { + try { + const { userId, userEmail } = await requireAuth(request) + // Your protected logic here + } catch (error) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } +} +``` + +## Frontend Authentication Components + +### Login Page +**File**: `app/login/page.tsx` + +```typescript +'use client' +import { signIn, getSession } from "next-auth/react" +import { useEffect } from "react" +import { useRouter } from "next/navigation" +import { Button } from "@/components/ui/button" + +export default function LoginPage() { + const router = useRouter() + + useEffect(() => { + // Redirect if already authenticated + getSession().then((session) => { + if (session) { + router.push('/chat') + } + }) + }, [router]) + + const handleSignIn = () => { + signIn('google', { callbackUrl: '/chat' }) + } + + return ( +
+
+
+

Sign in to Auto-Analyst

+

+ Access your AI-powered analytics platform +

+
+ + +
+
+ ) +} +``` + +### Sign Out Page +**File**: `app/signout/page.tsx` + +```typescript +'use client' +import { signOut, useSession } from "next-auth/react" +import { useEffect } from "react" +import { useRouter } from "next/navigation" + +export default function SignOutPage() { + const { data: session } = useSession() + const router = useRouter() + + useEffect(() => { + if (session) { + signOut({ callbackUrl: '/' }) + } else { + router.push('/') + } + }, [session, router]) + + return ( +
+
+

Signing you out...

+

Thank you for using Auto-Analyst

+
+
+ ) +} +``` + +### Navigation Auth State +**File**: `components/layout.tsx` + +```typescript +'use client' +import { useSession, signIn, signOut } from "next-auth/react" +import { Button } from "@/components/ui/button" +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar" + +function AuthButton() { + const { data: session, status } = useSession() + + if (status === "loading") { + return
+ } + + if (status === "unauthenticated") { + return ( + + ) + } + + return ( +
+ + + + {session.user?.name?.charAt(0) || 'U'} + + + + +
+ ) +} +``` + +## Route Protection + +### Page-Level Protection + +```typescript +// app/chat/page.tsx +import { getServerSession } from "next-auth/next" +import { authOptions } from "@/app/api/auth/[...nextauth]/route" +import { redirect } from "next/navigation" + +export default async function ChatPage() { + const session = await getServerSession(authOptions) + + if (!session) { + redirect('/login') + } + + return ( +
+

Welcome to Chat, {session.user?.name}

+ {/* Chat interface */} +
+ ) +} +``` + +### Middleware Protection + +```typescript +// middleware.ts +import { withAuth } from "next-auth/middleware" + +export default withAuth( + function middleware(req) { + // Additional middleware logic if needed + }, + { + callbacks: { + authorized: ({ token }) => !!token + }, + } +) + +export const config = { + matcher: [ + '/chat/:path*', + '/account/:path*', + '/analytics/:path*', + '/api/user/:path*', + '/api/trial/:path*', + '/api/checkout-sessions/:path*' + ] +} +``` + +## User Profile Management + +### Storing User Data + +```typescript +// When user signs in, store profile in Redis +export async function storeUserProfile(userId: string, userData: any) { + await redis.hset(KEYS.USER_PROFILE(userId), { + email: userData.email, + name: userData.name || 'User', + image: userData.image || '', + joinedDate: new Date().toISOString().split('T')[0], + lastLogin: new Date().toISOString() + }) +} +``` + +### Accessing User Profile + +```typescript +// lib/redis.ts - profileUtils +export const profileUtils = { + async getUserProfile(userId: string) { + const profile = await redis.hgetall(KEYS.USER_PROFILE(userId)) + return profile || {} + }, + + async updateUserProfile(userId: string, updates: any) { + await redis.hset(KEYS.USER_PROFILE(userId), { + ...updates, + lastUpdated: new Date().toISOString() + }) + } +} +``` + +## Session Security + +### JWT Configuration + +```typescript +// Secure JWT settings +const jwtOptions = { + secret: process.env.NEXTAUTH_SECRET, + encryption: true, + maxAge: 30 * 24 * 60 * 60, // 30 days + algorithm: 'HS256' +} +``` + +### Session Validation + +```typescript +// Enhanced token validation +export async function validateSession(request: NextRequest) { + const token = await getToken({ req: request }) + + if (!token) { + throw new Error('No session token') + } + + // Check token expiration + if (Date.now() >= token.exp * 1000) { + throw new Error('Token expired') + } + + // Validate user still exists + const userExists = await redis.exists(KEYS.USER_PROFILE(token.sub)) + if (!userExists) { + throw new Error('User not found') + } + + return token +} +``` + +## Error Handling + +### Authentication Errors + +```typescript +// Common authentication error patterns +export function handleAuthError(error: any) { + switch (error.type) { + case 'OAuthAccountNotLinked': + return 'Account already exists with different provider' + case 'EmailCreateAccount': + return 'Account creation failed' + case 'Callback': + return 'Authentication callback failed' + case 'OAuthCallback': + return 'OAuth provider callback failed' + case 'OAuthCreateAccount': + return 'Failed to create OAuth account' + case 'SessionRequired': + return 'Please sign in to continue' + default: + return 'Authentication failed' + } +} +``` + +### Error Pages + +```typescript +// app/login/error/page.tsx +'use client' +import { useSearchParams } from 'next/navigation' + +export default function AuthError() { + const searchParams = useSearchParams() + const error = searchParams.get('error') + + const errorMessage = handleAuthError({ type: error }) + + return ( +
+
+

+ Authentication Error +

+

{errorMessage}

+ +
+
+ ) +} +``` + +## Testing Authentication + +### Local Development + +```bash +# Set up Google OAuth for localhost +# In Google Cloud Console: +# - Create OAuth 2.0 credentials +# - Add http://localhost:3000 to authorized origins +# - Add http://localhost:3000/api/auth/callback/google to redirect URIs +``` + +### Test User Flows + +1. **Sign In Flow** + ```typescript + // Test successful sign in + await signIn('google') + // Should redirect to /chat + ``` + +2. **Protected Route Access** + ```typescript + // Access protected route without auth + // Should redirect to /login + ``` + +3. **Session Persistence** + ```typescript + // Refresh page after sign in + // Should maintain session + ``` + +4. **Sign Out Flow** + ```typescript + // Test sign out + await signOut() + // Should clear session and redirect + ``` + +## Best Practices + +### Security +1. **Always validate sessions** server-side +2. **Use HTTPS** in production +3. **Secure environment variables** properly +4. **Implement CSRF protection** (built into NextAuth) +5. **Monitor authentication events** + +### Performance +1. **Cache session data** appropriately +2. **Minimize token size** +3. **Use efficient session storage** +4. **Implement session refresh** logic + +### User Experience +1. **Clear error messages** for auth failures +2. **Smooth redirect flows** after sign in +3. **Loading states** during authentication +4. **Remember user preferences** across sessions + +### Development +1. **Consistent auth patterns** across the app +2. **Centralized auth logic** in utilities +3. **Comprehensive error handling** +4. **Authentication testing** in CI/CD \ No newline at end of file diff --git a/auto-analyst-frontend/docs/cancellation-flows.md b/auto-analyst-frontend/docs/cancellation-flows.md new file mode 100644 index 00000000..e44cdbf2 --- /dev/null +++ b/auto-analyst-frontend/docs/cancellation-flows.md @@ -0,0 +1,552 @@ +# Cancellation Flows + +This document covers all cancellation scenarios in Auto-Analyst, including trial cancellations, subscription cancellations, and the different handling logic for each case. + +## Overview + +Auto-Analyst handles two distinct cancellation scenarios with different behaviors: + +1. **Trial Cancellation** (0-7 days): Immediate access removal, no charges +2. **Post-Payment Cancellation** (7+ days): Access maintained until period end + +## Cancellation Types + +### 1. Trial Cancellation (Immediate) + +**When**: User cancels during 7-day trial period +**Effect**: Immediate access removal, no charges ever made +**Implementation**: Cancel Stripe subscription immediately + +```typescript +// Subscription status: 'trialing' +await stripe.subscriptions.cancel(subscriptionId, { + prorate: false +}) + +// Immediate credit removal +await creditUtils.setZeroCredits(userId) +``` + +### 2. Active Subscription Cancellation (End of Period) + +**When**: User cancels after trial conversion (paid subscription) +**Effect**: Access maintained until billing period ends +**Implementation**: Set `cancel_at_period_end: true` + +```typescript +// Subscription status: 'active' +await stripe.subscriptions.update(subscriptionId, { + cancel_at_period_end: true +}) + +// Access maintained until period_end +// Credits preserved until cancellation date +``` + +## Implementation + +### Unified Cancellation Endpoint + +**File**: `app/api/trial/cancel/route.ts` + +```typescript +export async function POST(request: NextRequest) { + const token = await getToken({ req: request }) + const userId = token.sub + + // Get user's subscription data + const subscriptionData = await redis.hgetall(KEYS.USER_SUBSCRIPTION(userId)) + const subscriptionId = subscriptionData.stripeSubscriptionId + + if (!subscriptionId) { + return NextResponse.json( + { error: 'No subscription found' }, + { status: 404 } + ) + } + + // Retrieve current subscription from Stripe + const subscription = await stripe.subscriptions.retrieve(subscriptionId) + + if (subscription.status === 'trialing') { + // TRIAL CANCELLATION: Immediate + return await handleTrialCancellation(userId, subscriptionId) + } else if (subscription.status === 'active') { + // ACTIVE CANCELLATION: End of period + return await handleActiveCancellation(userId, subscription) + } else { + return NextResponse.json( + { error: `Cannot cancel subscription with status: ${subscription.status}` }, + { status: 400 } + ) + } +} +``` + +### Trial Cancellation Handler + +```typescript +async function handleTrialCancellation( + userId: string, + subscriptionId: string +) { + try { + // Cancel subscription immediately + await stripe.subscriptions.cancel(subscriptionId, { + prorate: false + }) + + // Mark as trial cancellation in Redis + await redis.hset(KEYS.USER_CREDITS(userId), { + trialCanceled: 'true', + canceledAt: new Date().toISOString() + }) + + // Update subscription status + await redis.hset(KEYS.USER_SUBSCRIPTION(userId), { + status: 'canceled', + displayStatus: 'canceled', + canceledAt: new Date().toISOString(), + subscriptionCanceled: 'true' + }) + + console.log(`Trial canceled for user ${userId}`) + + return NextResponse.json({ + success: true, + canceled: true, + creditsRemoved: true, + message: 'Trial canceled successfully. You will not be charged.' + }) + } catch (error) { + console.error('Trial cancellation error:', error) + return NextResponse.json( + { error: 'Failed to cancel trial' }, + { status: 500 } + ) + } +} +``` + +### Active Subscription Cancellation Handler + +```typescript +async function handleActiveCancellation( + userId: string, + subscription: Stripe.Subscription +) { + try { + // Set cancel at period end + const updatedSubscription = await stripe.subscriptions.update( + subscription.id, + { cancel_at_period_end: true } + ) + + const periodEnd = new Date(subscription.current_period_end * 1000) + + // Update Redis with cancellation info + await redis.hset(KEYS.USER_SUBSCRIPTION(userId), { + cancel_at_period_end: 'true', + willCancelAt: periodEnd.toISOString(), + displayStatus: 'canceling', + lastUpdated: new Date().toISOString() + }) + + console.log(`Active subscription marked for cancellation: ${userId}`) + + return NextResponse.json({ + success: true, + canceledAtPeriodEnd: true, + accessUntil: periodEnd.toISOString(), + message: `Your subscription will cancel on ${periodEnd.toLocaleDateString()}. You'll maintain access until then.` + }) + } catch (error) { + console.error('Active cancellation error:', error) + return NextResponse.json( + { error: 'Failed to cancel subscription' }, + { status: 500 } + ) + } +} +``` + +## Webhook Handling + +### Subscription Deletion Webhook + +**Event**: `customer.subscription.deleted` + +```typescript +case 'customer.subscription.deleted': { + const subscription = event.data.object as Stripe.Subscription + const userId = await getUserIdFromCustomerId(subscription.customer as string) + + if (!userId) { + console.error('No userId found for customer:', subscription.customer) + break + } + + // Update subscription status + await redis.hset(KEYS.USER_SUBSCRIPTION(userId), { + status: 'canceled', + stripeSubscriptionStatus: 'canceled', + displayStatus: 'canceled', + canceledAt: new Date().toISOString(), + subscriptionDeleted: 'true', + lastUpdated: new Date().toISOString(), + syncedAt: new Date().toISOString() + }) + + // Set credits to 0 for canceled subscriptions + await creditUtils.setZeroCredits(userId) + + console.log(`Subscription deleted for user ${userId}, credits set to 0`) + break +} +``` + +### Subscription Update Webhook + +**Event**: `customer.subscription.updated` + +```typescript +case 'customer.subscription.updated': { + const subscription = event.data.object as Stripe.Subscription + const userId = await getUserIdFromCustomerId(subscription.customer as string) + + // Handle cancel_at_period_end status + if (subscription.status === 'active' && subscription.cancel_at_period_end) { + await redis.hset(KEYS.USER_SUBSCRIPTION(userId), { + status: 'active', + displayStatus: 'canceling', + cancel_at_period_end: 'true', + willCancelAt: new Date(subscription.current_period_end * 1000).toISOString(), + lastUpdated: new Date().toISOString(), + syncedAt: new Date().toISOString() + }) + } else { + await redis.hset(KEYS.USER_SUBSCRIPTION(userId), { + status: subscription.status, + displayStatus: subscription.status, + cancel_at_period_end: subscription.cancel_at_period_end.toString(), + lastUpdated: new Date().toISOString(), + syncedAt: new Date().toISOString() + }) + } + + break +} +``` + +## Credit Management During Cancellation + +### Trial Cancellation Credit Logic + +```typescript +// Immediate credit removal for trial cancellations +async function handleTrialCreditRemoval(userId: string) { + await redis.hset(KEYS.USER_CREDITS(userId), { + total: '0', + used: '0', + trialCanceled: 'true', + canceledAt: new Date().toISOString(), + lastUpdate: new Date().toISOString() + }) + + console.log(`Credits removed for trial cancellation: ${userId}`) +} +``` + +### Active Subscription Credit Logic + +```typescript +// Credits preserved until period end +async function handleActiveCreditPreservation( + userId: string, + periodEndDate: Date +) { + // Credits remain active until cancellation date + // No immediate changes to credit allocation + + await redis.hset(KEYS.USER_CREDITS(userId), { + willResetAt: periodEndDate.toISOString(), + pendingCancellation: 'true', + lastUpdate: new Date().toISOString() + }) + + console.log(`Credits preserved until ${periodEndDate.toISOString()} for user ${userId}`) +} +``` + +## Status Display Logic + +### Frontend Status Mapping + +```typescript +function getDisplayStatus(subscription: SubscriptionData) { + // Use displayStatus if available (for UI-specific states) + if (subscription.displayStatus) { + return { + status: subscription.displayStatus, + color: getStatusColor(subscription.displayStatus), + message: getStatusMessage(subscription.displayStatus) + } + } + + // Fallback to subscription status + return { + status: subscription.status, + color: getStatusColor(subscription.status), + message: getStatusMessage(subscription.status) + } +} + +function getStatusColor(status: string) { + switch (status) { + case 'trialing': return 'blue' + case 'active': return 'green' + case 'canceling': return 'amber' // Special UI state + case 'canceled': return 'red' + case 'past_due': return 'orange' + default: return 'gray' + } +} + +function getStatusMessage(status: string) { + switch (status) { + case 'trialing': return 'Trial period active' + case 'active': return 'Subscription active' + case 'canceling': return 'Will cancel at period end' + case 'canceled': return 'Subscription canceled' + case 'past_due': return 'Payment past due' + default: return 'Unknown status' + } +} +``` + +### Account Page Display + +```typescript +// components/account/SubscriptionStatus.tsx +function SubscriptionStatus({ subscription }: { subscription: SubscriptionData }) { + const displayStatus = getDisplayStatus(subscription) + + return ( +
+ + {displayStatus.status} + + + {subscription.status === 'canceling' && ( +
+ Cancels on {new Date(subscription.willCancelAt).toLocaleDateString()} +
+ )} + + {subscription.status === 'trialing' && ( +
+ Trial ends {new Date(subscription.trialEndDate).toLocaleDateString()} +
+ )} +
+ ) +} +``` + +## Cancellation Prevention + +### Retry Logic for Failed Payments + +```typescript +// Webhook: invoice.payment_failed +case 'invoice.payment_failed': { + const invoice = event.data.object as Stripe.Invoice + + // Don't immediately cancel - Stripe will retry + console.log(`Payment failed for invoice ${invoice.id}`, { + attemptCount: invoice.attempt_count, + nextPaymentAttempt: invoice.next_payment_attempt + }) + + // Update status to past_due but maintain access + await redis.hset(KEYS.USER_SUBSCRIPTION(userId), { + status: 'past_due', + displayStatus: 'past_due', + lastPaymentFailure: new Date().toISOString() + }) + + break +} +``` + +### Win-Back Campaigns + +```typescript +// Trigger win-back email for canceling subscriptions +async function triggerWinBackCampaign(userId: string, cancellationReason?: string) { + const userData = await redis.hgetall(KEYS.USER_PROFILE(userId)) + + // Send targeted email based on cancellation timing + if (subscription.status === 'trialing') { + await sendEmail(userData.email, 'trial-cancellation-winback') + } else { + await sendEmail(userData.email, 'subscription-cancellation-winback') + } +} +``` + +## Reactivation + +### Resubscribe Flow + +```typescript +// Allow users to reactivate canceled subscriptions +async function reactivateSubscription(userId: string, subscriptionId: string) { + try { + // Remove cancel_at_period_end flag + const subscription = await stripe.subscriptions.update(subscriptionId, { + cancel_at_period_end: false + }) + + // Update Redis status + await redis.hset(KEYS.USER_SUBSCRIPTION(userId), { + status: 'active', + displayStatus: 'active', + cancel_at_period_end: 'false', + reactivatedAt: new Date().toISOString(), + lastUpdated: new Date().toISOString() + }) + + // Restore credits if needed + await subscriptionUtils.refreshCreditsIfNeeded(userId) + + return { success: true, reactivated: true } + } catch (error) { + console.error('Reactivation failed:', error) + throw error + } +} +``` + +## Error Handling + +### Cancellation Errors + +```typescript +// Handle common cancellation errors +async function handleCancellationError(error: any, userId: string) { + switch (error.code) { + case 'resource_missing': + // Subscription not found + await cleanupOrphanedSubscription(userId) + break + + case 'subscription_canceled': + // Already canceled + await syncCancellationStatus(userId) + break + + default: + console.error('Unexpected cancellation error:', error) + throw error + } +} +``` + +### Data Consistency + +```typescript +// Ensure Redis and Stripe stay in sync +async function validateCancellationState(userId: string) { + const redisData = await redis.hgetall(KEYS.USER_SUBSCRIPTION(userId)) + const stripeSubscription = await stripe.subscriptions.retrieve( + redisData.stripeSubscriptionId + ) + + // Check for discrepancies + if (redisData.status !== stripeSubscription.status) { + console.warn('Status mismatch detected:', { + userId, + redis: redisData.status, + stripe: stripeSubscription.status + }) + + // Sync with Stripe as source of truth + await syncSubscriptionStatus(userId, stripeSubscription) + } +} +``` + +## Testing Cancellation Flows + +### Test Scenarios + +1. **Trial Cancellation** + ```bash + # Start trial, then cancel within 7 days + POST /api/trial/cancel + # Expected: Immediate access removal, no charge + ``` + +2. **Active Cancellation** + ```bash + # Convert trial to paid, then cancel + POST /api/trial/cancel + # Expected: Access until period end + ``` + +3. **Webhook Cancellation** + ```bash + stripe trigger customer.subscription.deleted + # Expected: Cleanup in Redis, credits zeroed + ``` + +4. **Reactivation** + ```bash + # Cancel subscription, then reactivate + # Expected: Restored access and credits + ``` + +## Monitoring + +### Cancellation Metrics + +- **Trial Cancellation Rate**: % of trials canceled before conversion +- **Churn Rate**: % of paid subscriptions canceled per month +- **Reactivation Rate**: % of canceled users who resubscribe +- **Time to Cancel**: Average days from signup to cancellation + +### Alerting + +```typescript +// Monitor for unusual cancellation patterns +const cancellationMetrics = { + trialCancellations: await getTrialCancellations('last_24h'), + activeCancellations: await getActiveCancellations('last_24h'), + reactivations: await getReactivations('last_24h') +} + +if (cancellationMetrics.trialCancellations > threshold.trial) { + await sendAlert('High trial cancellation rate detected') +} +``` + +## Best Practices + +### Cancellation UX +1. **Clear consequences**: Explain what happens when canceling +2. **Retention offers**: Present alternatives before confirming +3. **Easy reactivation**: Allow users to easily resubscribe +4. **Feedback collection**: Understand why users are canceling + +### Technical Implementation +1. **Immediate feedback**: Confirm cancellation success instantly +2. **Data consistency**: Keep Redis and Stripe synchronized +3. **Graceful degradation**: Handle edge cases smoothly +4. **Comprehensive logging**: Track all cancellation events + +### Business Logic +1. **Preserve trial conversion**: Don't make trials too restrictive +2. **Honor commitments**: Maintain access for paid periods +3. **Win-back opportunities**: Engage canceled users appropriately +4. **Analytics driven**: Use data to improve retention \ No newline at end of file diff --git a/auto-analyst-frontend/docs/credit-system.md b/auto-analyst-frontend/docs/credit-system.md new file mode 100644 index 00000000..123b6dbd --- /dev/null +++ b/auto-analyst-frontend/docs/credit-system.md @@ -0,0 +1,468 @@ +# Credit System + +This document covers the complete credit management system in Auto-Analyst, including allocation, usage tracking, and billing logic. + +## Overview + +Auto-Analyst uses a credit-based billing system where users consume credits for various features like chat analysis, code execution, and deep analysis. Credits are allocated based on subscription plans and reset monthly. + +## Credit Configuration + +### Plan Types and Credits + +Located in `lib/credits-config.ts`: + +```typescript +export const PLAN_CREDITS: Record = { + 'Trial': { + total: 500, + displayName: 'Trial Plan', + type: 'TRIAL', + isUnlimited: false, + minimum: 0 + }, + 'Standard': { + total: 500, + displayName: 'Standard Plan', + type: 'STANDARD', + isUnlimited: false, + minimum: 0 + }, + 'Pro': { + total: 999999, + displayName: 'Pro Plan', + type: 'PRO', + isUnlimited: true, + minimum: 0 + } +} +``` + +### Credit Utilities + +```typescript +export const CreditConfig = { + // Get credits by plan type + getCreditsByType: (type: PlanType) => PLAN_CREDITS[type], + + // Get credits by plan name + getCreditsForPlan: (planName: string) => { /* implementation */ }, + + // Check if unlimited credits + isUnlimitedTotal: (total: number) => total >= 999999, + + // Get next reset date + getNextResetDate: () => { + const now = new Date() + const nextMonth = new Date(now.getFullYear(), now.getMonth() + 1, 1) + return nextMonth.toISOString().split('T')[0] + } +} +``` + +## Credit Lifecycle + +### 1. Credit Initialization + +**For Trial Users**: +```typescript +// app/api/trial/start/route.ts +await creditUtils.initializeTrialCredits(userId, { + total: 500, + resetDate: CreditConfig.getNextResetDate(), + paymentIntentId: paymentIntent.id +}) +``` + +**For Subscription Users**: +```typescript +// Handled by webhook: invoice.payment_succeeded +await subscriptionUtils.refreshCreditsIfNeeded(userId) +``` + +### 2. Credit Reset Schedule + +Credits reset monthly on the 1st of each month: + +```typescript +// lib/redis.ts - refreshCreditsIfNeeded() +const now = new Date() +const resetDate = new Date(creditsData.resetDate) + +if (now >= resetDate) { + // Reset credits to plan amount + const planCredits = CreditConfig.getCreditsByType(planType) + + await redis.hset(KEYS.USER_CREDITS(userId), { + total: planCredits.credits.toString(), + used: '0', + resetDate: CreditConfig.getNextResetDate(), + lastUpdate: new Date().toISOString() + }) +} +``` + +### 3. Credit Usage Tracking + +#### Deduction Process +```typescript +// app/api/user/deduct-credits/route.ts +export async function POST(request: NextRequest) { + const { amount, description } = await request.json() + const token = await getToken({ req: request }) + const userId = token.sub + + // Attempt to deduct credits + const success = await creditUtils.deductCredits(userId, amount) + + if (!success) { + return NextResponse.json( + { + error: "Insufficient credits", + required: amount, + available: await creditUtils.getRemainingCredits(userId) + }, + { status: 402 } + ) + } + + // Return updated credit info + const remaining = await creditUtils.getRemainingCredits(userId) + return NextResponse.json({ + success: true, + remainingCredits: remaining, + deducted: amount, + description + }) +} +``` + +#### Atomic Deduction Logic +```typescript +// lib/redis.ts - creditUtils.deductCredits() +async deductCredits(userId: string, amount: number): Promise { + const creditsHash = await redis.hgetall(KEYS.USER_CREDITS(userId)) + + if (!creditsHash || !creditsHash.total) { + return false // No credits available + } + + const total = parseInt(creditsHash.total) + const used = parseInt(creditsHash.used || '0') + const available = total - used + + if (available < amount) { + return false // Insufficient credits + } + + // Atomic update + await redis.hset(KEYS.USER_CREDITS(userId), { + used: (used + amount).toString(), + lastUpdate: new Date().toISOString() + }) + + return true +} +``` + +## Feature Credit Costs + +### Current Credit Pricing + +```typescript +export const FEATURE_COSTS = { + /** Cost for deep analysis - premium feature for paid users */ + DEEP_ANALYSIS: 50, +} + +// Model credit costs are based on MODEL_TIERS configuration: +// - Basic models: 1-2 credits +// - Standard models: 3-5 credits +// - Premium models: 6-10 credits +// - Premium+ models: 11-20 credits +``` + +### Usage Implementation + +```typescript +// For deep analysis feature +const creditsNeeded = FEATURE_COSTS.DEEP_ANALYSIS // 50 credits + +const deductResponse = await fetch('/api/user/deduct-credits', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + amount: creditsNeeded, + description: 'Deep analysis report generation' + }) +}) + +if (deductResponse.status === 402) { + // Show upgrade prompt - insufficient credits + showInsufficientCreditsModal() + return +} + +// For AI model usage (chat messages) +const modelCost = getModelCreditCost(selectedModel) // 1-20 credits based on model tier +// Credit deduction happens automatically in backend based on model used +``` + +## Credit Storage Schema + +### Redis Structure + +```typescript +// Key: user:${userId}:credits +{ + total: string // "500" + used: string // "150" + resetDate: string // "2024-02-01" + lastUpdate: string // ISO timestamp + resetInterval: string // "month" + + // Trial-specific fields + isTrialCredits?: string // "true" + paymentIntentId?: string // Stripe payment intent + + // Cancellation tracking + trialCanceled?: string // "true" + subscriptionDeleted?: string // "true" + + // Admin fields + nextTotalCredits?: string // For plan changes + pendingDowngrade?: string // "true" +} +``` + +### Data Validation + +```typescript +function validateCreditData(creditsData: any) { + const total = parseInt(creditsData.total || '0') + const used = parseInt(creditsData.used || '0') + + // Validation rules + if (total < 0) throw new Error('Total credits cannot be negative') + if (used < 0) throw new Error('Used credits cannot be negative') + if (used > total && total < 999999) { + throw new Error('Used credits cannot exceed total') + } + + return { total, used, remaining: total - used } +} +``` + +## Credit Refresh Logic + +### Automatic Refresh Triggers + +1. **User Data API Call** (`/api/user/data`) +2. **Credit Check API Call** (`/api/user/credits`) +3. **Webhook Events** (payment succeeded, subscription updated) +4. **Navigation Events** (chat page load with refresh flag) + +### Refresh Implementation + +```typescript +// lib/redis.ts - subscriptionUtils.refreshCreditsIfNeeded() +async refreshCreditsIfNeeded(userId: string): Promise { + const creditsData = await redis.hgetall(KEYS.USER_CREDITS(userId)) + const subscriptionData = await redis.hgetall(KEYS.USER_SUBSCRIPTION(userId)) + + // Check if credits need reset (monthly) + const now = new Date() + const resetDate = new Date(creditsData.resetDate || now) + + if (now >= resetDate) { + await this.resetMonthlyCredits(userId) + return true + } + + // Check for plan changes + if (this.hasPlanChanged(creditsData, subscriptionData)) { + await this.updateCreditsForPlanChange(userId) + return true + } + + // Check for cancellations + if (this.shouldZeroCredits(creditsData, subscriptionData)) { + await creditUtils.setZeroCredits(userId) + return true + } + + return false +} +``` + +### Reset Logic + +```typescript +async resetMonthlyCredits(userId: string) { + const subscriptionData = await redis.hgetall(KEYS.USER_SUBSCRIPTION(userId)) + const planType = subscriptionData.planType || 'STANDARD' + const planCredits = CreditConfig.getCreditsByType(planType) + + await redis.hset(KEYS.USER_CREDITS(userId), { + total: planCredits.credits.toString(), + used: '0', + resetDate: CreditConfig.getNextResetDate(), + lastUpdate: new Date().toISOString(), + resetInterval: 'month' + }) + + console.log(`Credits reset for user ${userId}: ${planCredits.credits} credits`) +} +``` + +## Credit Preservation + +### Successful Trial Conversion +```typescript +// Credits are preserved when trial converts to paid +// Only genuine cancellations zero out credits + +function shouldZeroCredits(creditsData: any, subscriptionData: any): boolean { + // Genuine trial cancellation + if (creditsData.trialCanceled === 'true') return true + + // Subscription deleted (not converted) + if (creditsData.subscriptionDeleted === 'true') return true + + // Status is canceled and marked for cleanup + if (subscriptionData.status === 'canceled' && + subscriptionData.subscriptionCanceled === 'true') return true + + return false +} +``` + +### Plan Downgrade Protection +```typescript +// Credits are preserved during plan changes within billing period +// Only reset to new plan amount at next billing cycle + +async updateCreditsForPlanChange(userId: string) { + const subscriptionData = await redis.hgetall(KEYS.USER_SUBSCRIPTION(userId)) + const newPlanType = subscriptionData.planType + const newPlanCredits = CreditConfig.getCreditsByType(newPlanType) + + // Set next month's credit allocation + await redis.hset(KEYS.USER_CREDITS(userId), { + nextTotalCredits: newPlanCredits.credits.toString(), + pendingDowngrade: newPlanCredits.credits < currentTotal ? 'true' : 'false' + }) +} +``` + +## Credit Display + +### Frontend Components + +#### Credit Balance Display +```typescript +// components/chat/CreditBalance.tsx +function CreditBalance() { + const [credits, setCredits] = useState({ used: 0, total: 0 }) + const [isRefreshing, setIsRefreshing] = useState(false) + + const refreshCredits = async () => { + setIsRefreshing(true) + const response = await fetch('/api/user/credits') + const data = await response.json() + setCredits(data) + setIsRefreshing(false) + } + + const percentage = credits.total === Infinity ? 100 : + Math.round(((credits.total - credits.used) / credits.total) * 100) + + return ( +
+ {isRefreshing ? ( + + ) : ( + {credits.total - credits.used} / {credits.total} + )} + +
+ ) +} +``` + +#### Usage Warning +```typescript +function InsufficientCreditsModal({ requiredCredits, availableCredits }) { + return ( + + + + Insufficient Credits + + You need {requiredCredits} credits but only have {availableCredits} remaining. + Upgrade your plan to continue using this feature. + + + + + + + + ) +} +``` + +## Monitoring and Analytics + +### Credit Usage Tracking +```typescript +// Log credit usage for analytics +console.log('Credit deduction:', { + userId, + amount, + feature: description, + remainingCredits, + timestamp: new Date().toISOString() +}) +``` + +### Key Metrics +- Average credits per user per month +- Feature usage patterns +- Credit exhaustion rates +- Conversion rates from credit exhaustion + +### Alerts +- Users approaching credit limits +- Unusual usage patterns +- Failed credit deductions +- System errors in credit processing + +## Best Practices + +### Credit Management +1. **Always check credits** before feature usage +2. **Handle insufficient credits gracefully** with upgrade prompts +3. **Use atomic operations** for credit deduction +4. **Preserve credits** during legitimate state transitions +5. **Reset credits monthly** regardless of billing cycle + +### Error Handling +1. **Graceful degradation** when credit system fails +2. **Clear error messages** for users +3. **Retry logic** for temporary failures +4. **Logging** for debugging and monitoring + +### Performance +1. **Cache credit data** in component state +2. **Batch credit operations** when possible +3. **Use Redis efficiently** with hash operations +4. **Minimize API calls** with smart refresh logic + +### Security +1. **Validate all credit operations** server-side +2. **Prevent credit manipulation** via client +3. **Audit credit changes** with timestamps +4. **Rate limit** credit-related endpoints \ No newline at end of file diff --git a/auto-analyst-frontend/docs/redis-schema.md b/auto-analyst-frontend/docs/redis-schema.md new file mode 100644 index 00000000..6626f81b --- /dev/null +++ b/auto-analyst-frontend/docs/redis-schema.md @@ -0,0 +1,292 @@ +# Redis Data Schema + +This document describes the complete Redis data structure used in Auto-Analyst for user management, subscriptions, and credit tracking. + +## Overview + +Auto-Analyst uses **Upstash Redis** with a hash-based storage pattern for efficient data access and atomic operations. All user data is organized using consistent key patterns. + +## Environment Variables + +```bash +UPSTASH_REDIS_REST_URL=your_redis_url +UPSTASH_REDIS_REST_TOKEN=your_redis_token +``` + +## Key Patterns + +### Core Keys +```typescript +export const KEYS = { + USER_PROFILE: (userId: string) => `user:${userId}:profile`, + USER_SUBSCRIPTION: (userId: string) => `user:${userId}:subscription`, + USER_CREDITS: (userId: string) => `user:${userId}:credits`, +}; + +// Additional keys +stripe:customer:${customerId} -> userId (string mapping) +``` + +## Data Structures + +### 1. User Profile (`user:${userId}:profile`) + +**Hash Structure:** +```typescript +{ + email: string // User's email address + name: string // User's display name + image: string // Profile image URL + joinedDate: string // ISO date string (YYYY-MM-DD) + role: string // Plan display name (e.g., "Standard Plan") +} +``` + +**Example:** +```json +{ + "email": "user@example.com", + "name": "John Doe", + "image": "https://example.com/avatar.jpg", + "joinedDate": "2024-01-15", + "role": "Standard Plan" +} +``` + +### 2. User Subscription (`user:${userId}:subscription`) + +**Hash Structure:** +```typescript +{ + // Core subscription data + plan: string // Plan display name + planType: string // Plan type (TRIAL, STANDARD, PRO, etc.) + status: string // Subscription status + displayStatus?: string // UI display status (for canceling subscriptions) + amount: string // Amount as string (e.g., "15.00") + interval: string // Billing interval (month, year) + + // Dates and timestamps + purchaseDate: string // ISO timestamp + renewalDate: string // Next billing date (YYYY-MM-DD) + lastUpdated: string // ISO timestamp + + // Stripe integration + stripeCustomerId: string // Stripe customer ID + stripeSubscriptionId: string // Stripe subscription ID + stripeSubscriptionStatus: string // Direct Stripe status + + // Trial management + trialStartDate?: string // ISO timestamp + trialEndDate?: string // ISO timestamp + trialEndedAt?: string // ISO timestamp + trialToActiveDate?: string // ISO timestamp + + // Cancellation tracking + canceledAt?: string // ISO timestamp + subscriptionCanceled?: string // "true" if canceled + cancel_at_period_end?: string // "true"/"false" + willCancelAt?: string // ISO timestamp + + // Yearly subscription support + isYearly?: boolean // True for yearly plans + nextMonthlyReset?: string // Next credit reset for yearly plans + + // Synchronization + syncedAt?: string // Last sync timestamp +} +``` + +**Example:** +```json +{ + "plan": "Standard Plan", + "planType": "STANDARD", + "status": "active", + "amount": "15.00", + "interval": "month", + "purchaseDate": "2024-01-15T10:30:00.000Z", + "renewalDate": "2024-02-15", + "lastUpdated": "2024-01-20T15:45:00.000Z", + "stripeCustomerId": "cus_ABC123", + "stripeSubscriptionId": "sub_DEF456", + "stripeSubscriptionStatus": "active" +} +``` + +### 3. User Credits (`user:${userId}:credits`) + +**Hash Structure:** +```typescript +{ + // Core credit data + total: string // Total credits as string + used: string // Used credits as string + resetDate: string // Next reset date (YYYY-MM-DD) + lastUpdate: string // ISO timestamp + resetInterval: string // "month" (always monthly) + + // Trial credits + isTrialCredits?: string // "true" if trial credits + paymentIntentId?: string // Associated payment intent + + // Cancellation tracking + trialCanceled?: string // "true" if trial was canceled + subscriptionDeleted?: string // "true" if subscription deleted + downgradedAt?: string // ISO timestamp + canceledAt?: string // ISO timestamp + + // Admin operations + nextTotalCredits?: string // Scheduled credit amount + pendingDowngrade?: string // "true" if downgrade pending +} +``` + +**Example:** +```json +{ + "total": "500", + "used": "150", + "resetDate": "2024-02-01", + "lastUpdate": "2024-01-20T15:45:00.000Z", + "resetInterval": "month" +} +``` + +### 4. Stripe Customer Mapping (`stripe:customer:${customerId}`) + +**Simple String Value:** +```typescript +// Key: stripe:customer:cus_ABC123 +// Value: "user123" +``` + +This mapping allows webhooks to find the correct user ID from Stripe customer events. + +## Data Operations + +### Reading Data +```typescript +// Get all subscription data +const subscriptionData = await redis.hgetall(KEYS.USER_SUBSCRIPTION(userId)) + +// Get specific field +const planType = await redis.hget(KEYS.USER_SUBSCRIPTION(userId), 'planType') + +// Get multiple fields +const creditInfo = await redis.hmget(KEYS.USER_CREDITS(userId), ['total', 'used', 'resetDate']) +``` + +### Writing Data +```typescript +// Update multiple fields atomically +await redis.hset(KEYS.USER_SUBSCRIPTION(userId), { + status: 'active', + lastUpdated: new Date().toISOString(), + stripeSubscriptionStatus: 'active' +}) + +// Set single field +await redis.hset(KEYS.USER_CREDITS(userId), 'used', '160') +``` + +### Atomic Operations +```typescript +// Check and update credits atomically +const creditsHash = await redis.hgetall(KEYS.USER_CREDITS(userId)) +if (creditsHash && creditsHash.total) { + const total = parseInt(creditsHash.total) + const used = parseInt(creditsHash.used || '0') + + if (total - used >= requiredCredits) { + await redis.hset(KEYS.USER_CREDITS(userId), { + used: (used + requiredCredits).toString(), + lastUpdate: new Date().toISOString() + }) + return true + } +} +return false +``` + +## Data Consistency + +### Webhook Synchronization +All Stripe events are synchronized to Redis via webhooks to ensure data consistency: + +1. **Subscription Updates**: `customer.subscription.updated` → Update subscription status +2. **Payment Success**: `invoice.payment_succeeded` → Activate subscription +3. **Payment Failure**: `invoice.payment_failed` → Handle failed payments +4. **Subscription Deletion**: `customer.subscription.deleted` → Clean up data + +### Error Handling +```typescript +try { + const result = await redis.hgetall(KEYS.USER_SUBSCRIPTION(userId)) + return result || {} +} catch (error) { + console.error('Redis error:', error) + return {} // Graceful fallback +} +``` + +## Performance Considerations + +### Batch Operations +```typescript +// Use pipeline for multiple operations +const pipeline = redis.pipeline() +pipeline.hset(KEYS.USER_SUBSCRIPTION(userId), subscriptionData) +pipeline.hset(KEYS.USER_CREDITS(userId), creditData) +await pipeline.exec() +``` + +### Caching Strategy +- **TTL**: No TTL set on user data (persistent) +- **Invalidation**: Data updated via webhooks and API calls +- **Fallback**: API endpoints provide fallback for missing data + +## Monitoring + +### Key Metrics to Monitor +- Redis memory usage +- Connection count +- Operation latency +- Error rates + +### Debug Endpoints +- `/api/debug/redis-check` - Check Redis data for specific user +- `/api/debug/sync-subscription` - Manual sync with Stripe + +## Migration Patterns + +### Adding New Fields +```typescript +// Safe field addition (backward compatible) +const subscriptionData = await redis.hgetall(KEYS.USER_SUBSCRIPTION(userId)) +const newField = subscriptionData.newField || 'defaultValue' +``` + +### Data Migration Script Example +```typescript +async function migrateSubscriptions() { + const keys = await redis.keys('user:*:subscription') + + for (const key of keys) { + const data = await redis.hgetall(key) + if (!data.planType) { + await redis.hset(key, 'planType', 'STANDARD') + } + } +} +``` + +## Best Practices + +1. **Always use hash operations** for user data +2. **Include timestamps** for debugging and auditing +3. **Handle missing data gracefully** with fallbacks +4. **Use atomic operations** for credit deduction +5. **Validate data types** when reading from Redis +6. **Log errors** but don't fail the user experience +7. **Use consistent key patterns** across the application \ No newline at end of file diff --git a/auto-analyst-frontend/docs/stripe-integration.md b/auto-analyst-frontend/docs/stripe-integration.md new file mode 100644 index 00000000..78c72a4d --- /dev/null +++ b/auto-analyst-frontend/docs/stripe-integration.md @@ -0,0 +1,382 @@ +# Stripe Integration + +This document covers the complete Stripe integration in Auto-Analyst, including payment processing, subscription management, and webhook handling. + +## Overview + +Auto-Analyst uses Stripe for: +- 7-day free trial with payment authorization +- Subscription billing (monthly/yearly) +- Payment processing +- Subscription lifecycle management +- Real-time event synchronization via webhooks + +## Environment Variables + +```bash +# Required Stripe Configuration +STRIPE_SECRET_KEY=sk_test_... # or sk_live_... for production +STRIPE_WEBHOOK_SECRET=whsec_... # Webhook endpoint secret + +# Client-side Stripe Configuration +NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_... # or pk_live_... for production +``` + +## Stripe Configuration + +### API Version +```typescript +const stripe = new Stripe(process.env.STRIPE_SECRET_KEY, { + apiVersion: '2025-02-24.acacia', +}) +``` + +### Required Stripe Products + +#### 1. Standard Plan +```json +{ + "name": "Standard Plan", + "prices": [ + { + "amount": 1500, // $15.00 + "currency": "usd", + "interval": "month" + } + ] +} +``` + +#### 2. Pro Plan (if available) +```json +{ + "name": "Pro Plan", + "prices": [ + { + "amount": 2500, // $25.00 + "currency": "usd", + "interval": "month" + } + ] +} +``` + +## Trial System Implementation + +### 7-Day Trial Flow + +1. **Subscription Creation** (`/api/checkout-sessions`) +2. **Payment Method Setup** (Setup intent for payment authorization) +3. **Trial Activation** (`/api/trial/start`) +4. **Trial Access** (500 credits immediately) +5. **Auto-Conversion** (After 7 days, Stripe charges automatically) + +### Subscription Creation Code +```typescript +// app/api/checkout-sessions/route.ts +// Auto-Analyst creates subscription directly, not via checkout sessions +const subscription = await stripe.subscriptions.create({ + customer: customerId, + items: [{ price: priceId }], + trial_end: trialEndTimestamp, // 7 days from now + expand: ['latest_invoice.payment_intent'], + payment_behavior: 'default_incomplete', + payment_settings: { + save_default_payment_method: 'on_subscription', + }, + metadata: { + userId: userId, + planName, + interval, + isTrial: 'true' + } +}) +``` + +## Subscription States + +### State Diagram +``` +[Trial Start] → [trialing] → [active] (payment successful) + ↓ + [canceled] (trial canceled) + ↓ + [subscription deleted] +``` + +### Status Mapping +```typescript +interface SubscriptionStatus { + stripe: 'trialing' | 'active' | 'canceled' | 'past_due' | 'unpaid' + redis: 'trialing' | 'active' | 'canceling' | 'canceled' | 'past_due' + display: 'Trial' | 'Active' | 'Canceling' | 'Canceled' | 'Past Due' +} +``` + +## Key Stripe Objects + +### Customer +```typescript +interface StripeCustomer { + id: string // cus_ABC123 + email: string + created: number + metadata: { + userId: string // Our internal user ID + } +} +``` + +### Subscription +```typescript +interface StripeSubscription { + id: string // sub_DEF456 + customer: string // cus_ABC123 + status: SubscriptionStatus + trial_start?: number // Unix timestamp + trial_end?: number // Unix timestamp + current_period_start: number // Unix timestamp + current_period_end: number // Unix timestamp + cancel_at_period_end: boolean // true if canceling + canceled_at?: number // Unix timestamp + metadata: { + userId: string + userEmail: string + isTrial: string + } +} +``` + +### Payment Intent +```typescript +interface StripePaymentIntent { + id: string // pi_GHI789 + customer: string // cus_ABC123 + status: PaymentIntentStatus + amount: number + metadata: { + userId: string + isTrial: string + } +} +``` + +## Subscription Management + +### Creating Trial Subscription +```typescript +// Direct subscription creation with trial +const subscription = await stripe.subscriptions.create({ + customer: customerId, + items: [{ price: priceId }], + trial_end: trialEndTimestamp, // 7 days from now + payment_behavior: 'default_incomplete', + metadata: { userId, isTrial: 'true' } +}) +``` + +### Canceling Subscription +```typescript +// app/api/trial/cancel/route.ts +if (subscription.status === 'trialing') { + // Immediate cancellation for trials + await stripe.subscriptions.cancel(subscriptionId, { + prorate: false + }) +} else { + // Cancel at period end for active subscriptions + await stripe.subscriptions.update(subscriptionId, { + cancel_at_period_end: true + }) +} +``` + +### Retrieving Subscription Data +```typescript +const subscription = await stripe.subscriptions.retrieve(subscriptionId, { + expand: ['latest_invoice', 'customer'] +}) +``` + +## Payment Processing + +### Authorization vs. Capture + +#### Trial Authorization +- **Authorization Only**: Payment method validated and authorized +- **No Immediate Charge**: User not charged until trial ends +- **Future Payment**: Stripe automatically charges after 7 days + +#### Active Subscription +- **Immediate Charge**: Regular subscription payments charged immediately +- **Recurring Billing**: Automatic monthly/yearly charges + +### Payment Method Validation +```typescript +// Validate payment method exists for trial +const paymentMethods = await stripe.paymentMethods.list({ + customer: customerId, + type: 'card' +}) + +if (paymentMethods.data.length === 0) { + throw new Error('No payment method found') +} +``` + +## Error Handling + +### Common Stripe Errors +```typescript +try { + await stripe.subscriptions.create(subscriptionData) +} catch (error) { + switch (error.code) { + case 'resource_missing': + // Customer or price not found + break + case 'card_declined': + // Payment method declined + break + case 'authentication_required': + // 3D Secure required + break + default: + // Generic error handling + } +} +``` + +### Webhook Error Handling +```typescript +// app/api/webhooks/route.ts +export async function POST(request: NextRequest) { + let event: Stripe.Event + + try { + const body = await request.text() + const signature = request.headers.get('stripe-signature')! + + event = stripe.webhooks.constructEvent( + body, + signature, + process.env.STRIPE_WEBHOOK_SECRET! + ) + } catch (err) { + console.error('Webhook signature verification failed:', err) + return NextResponse.json({ error: 'Invalid signature' }, { status: 400 }) + } + + // Process event... +} +``` + +## Testing + +### Test Cards +```typescript +// Successful payment +'4242424242424242' + +// Payment requires authentication +'4000002500003155' + +// Payment declined +'4000000000000002' + +// Insufficient funds +'4000000000009995' +``` + +### Test Scenarios + +#### 1. Successful Trial +```bash +# Create checkout session +POST /api/checkout-sessions +→ Redirects to Stripe checkout +→ User enters test card 4242424242424242 +→ checkout.session.completed webhook fires +→ User gets trial access +``` + +#### 2. Trial Cancellation +```bash +# Cancel during trial +POST /api/trial/cancel +→ Stripe subscription canceled +→ customer.subscription.deleted webhook fires +→ Credits set to 0 +``` + +#### 3. Failed Payment Authorization +```bash +# Use declined card +→ payment_intent.payment_failed webhook fires +→ Trial access prevented +``` + +## Monitoring and Logs + +### Key Metrics +- Trial conversion rate +- Payment success rate +- Churn rate +- Revenue metrics + +### Logging Strategy +```typescript +console.log(`Trial started for user ${userId}`) +console.log(`Payment succeeded for subscription ${subscriptionId}`) +console.error(`Payment failed for user ${userId}:`, error) +``` + +### Stripe Dashboard +- Monitor payment failures +- Track subscription metrics +- Review webhook deliveries +- Analyze customer lifecycle + +## Security Best Practices + +1. **Never expose secret keys** in client-side code +2. **Validate webhook signatures** for all incoming events +3. **Use HTTPS** for all webhook endpoints +4. **Implement idempotency** for webhook processing +5. **Log security events** for auditing +6. **Rate limit** API endpoints +7. **Validate user permissions** before subscription operations + +## Production Considerations + +### Webhook Reliability +```typescript +// Implement retry logic for failed webhook processing +const maxRetries = 3 +let retryCount = 0 + +while (retryCount < maxRetries) { + try { + await processWebhook(event) + break + } catch (error) { + retryCount++ + if (retryCount === maxRetries) { + throw error + } + await new Promise(resolve => setTimeout(resolve, 1000 * retryCount)) + } +} +``` + +### Database Consistency +- Use transactions for critical operations +- Implement eventual consistency for webhook data +- Add data validation and sanitization +- Monitor for data discrepancies + +### Performance +- Cache frequently accessed Stripe data +- Use webhook data to update local cache +- Implement request deduplication +- Monitor API rate limits \ No newline at end of file diff --git a/auto-analyst-frontend/docs/trial-system.md b/auto-analyst-frontend/docs/trial-system.md new file mode 100644 index 00000000..856f4fda --- /dev/null +++ b/auto-analyst-frontend/docs/trial-system.md @@ -0,0 +1,614 @@ +# Trial System + +This document covers the complete 7-day trial system implementation in Auto-Analyst, including payment authorization, access management, and conversion logic. + +## Overview + +Auto-Analyst uses a 7-day free trial with Stripe subscription trials. Users must authorize a payment method to access the trial but are not charged until the trial period ends. + +## Trial Flow Architecture + +### High-Level Flow +``` +[Start Trial] → [Payment Auth] → [Trial Access] → [Auto-Convert] OR [Cancel] + ↓ ↓ ↓ ↓ ↓ +[Pricing Page] → [Stripe Checkout] → [500 Credits] → [Paid Plan] → [Access Removed] +``` + +### Detailed Flow +1. User clicks "Start 7-Day Trial" on pricing page +2. Redirected to Stripe checkout with trial subscription +3. Payment method authorization required (no charge) +4. `checkout.session.completed` webhook fired +5. User calls `/api/trial/start` to activate trial +6. Immediate access with 500 Standard plan credits +7. After 7 days: Stripe automatically charges OR user cancels + +## Implementation Components + +### 1. Subscription Creation (Not Checkout Session) + +**File**: `app/api/checkout-sessions/route.ts` + +```typescript +export async function POST(request: NextRequest) { + const { priceId, userId, planName, interval, promoCode } = await request.json() + + // Create or retrieve customer + let customerId = await getOrCreateCustomer(userId) + + // Calculate trial end date + const trialEndTimestamp = TrialUtils.getTrialEndTimestamp() + + // Create subscription with trial period + const subscription = await stripe.subscriptions.create({ + customer: customerId, + items: [{ price: priceId }], + trial_end: trialEndTimestamp, + expand: ['latest_invoice.payment_intent'], + payment_behavior: 'default_incomplete', + payment_settings: { + save_default_payment_method: 'on_subscription', + }, + metadata: { + userId: userId || 'anonymous', + planName, + interval, + priceId, + isTrial: 'true', + trialEndDate: TrialUtils.getTrialEndDate(), + }, + // Apply coupon if provided + ...(couponId && { coupon: couponId }) + }) + + // Create setup intent for payment method collection if needed + let clientSecret = subscription.latest_invoice?.payment_intent?.client_secret + + if (!clientSecret && subscription.status === 'trialing') { + const setupIntent = await stripe.setupIntents.create({ + customer: customerId, + usage: 'off_session', + metadata: { + subscription_id: subscription.id, + is_trial_setup: 'true', + userId: userId || 'anonymous', + isTrial: 'true', + planName, + interval, + }, + }) + clientSecret = setupIntent.client_secret + } + + return NextResponse.json({ + subscriptionId: subscription.id, + clientSecret: clientSecret, + trialEnd: subscription.trial_end, + isTrialSetup: !subscription.latest_invoice?.payment_intent + }) +} +``` + +### 2. Trial Activation + +**File**: `app/api/trial/start/route.ts` + +```typescript +export async function POST(request: NextRequest) { + const { subscriptionId, planName, interval, amount } = await request.json() + const token = await getToken({ req: request }) + const userId = token.sub + + // Retrieve and validate subscription + const subscription = await stripe.subscriptions.retrieve(subscriptionId) + + if (subscription.status !== 'trialing') { + return NextResponse.json( + { error: 'Subscription is not in trial status' }, + { status: 400 } + ) + } + + // Validate payment method is attached + const hasPaymentMethod = await validatePaymentMethod(subscription) + + if (!hasPaymentMethod) { + return NextResponse.json( + { error: 'Payment method setup required. Please complete payment method verification.' }, + { status: 400 } + ) + } + + // Store customer mapping for webhooks + await redis.set(`stripe:customer:${subscription.customer}`, userId) + + // Store subscription data + const now = new Date() + const trialEndDate = TrialUtils.getTrialEndDate(now) + + await redis.hset(KEYS.USER_SUBSCRIPTION(userId), { + plan: planName, + planType: 'STANDARD', + status: 'trialing', + amount: amount ? amount.toString() : '15', + interval: interval || 'month', + purchaseDate: now.toISOString(), + renewalDate: trialEndDate, + lastUpdated: now.toISOString(), + stripeCustomerId: subscription.customer as string, + stripeSubscriptionId: subscription.id, + trialEndDate: trialEndDate + }) + + // Initialize trial credits + await creditUtils.initializeTrialCredits(userId, { + total: TrialUtils.getTrialCredits(), + resetDate: CreditConfig.getNextResetDate() + }) + + return NextResponse.json({ + success: true, + trialStarted: true, + credits: TrialUtils.getTrialCredits(), + trialEndDate: trialEndDate, + subscriptionId: subscription.id + }) +} +``` + +### 3. Payment Method Validation + +```typescript +async function validatePaymentMethod(customerId: string): Promise { + try { + // Check for attached payment methods + const paymentMethods = await stripe.paymentMethods.list({ + customer: customerId, + type: 'card' + }) + + if (paymentMethods.data.length > 0) return true + + // Check customer's default payment method + const customer = await stripe.customers.retrieve(customerId) + if (customer.invoice_settings?.default_payment_method) return true + + // Check setup intents for this customer + const setupIntents = await stripe.setupIntents.list({ + customer: customerId, + limit: 1 + }) + + return setupIntents.data.some(si => si.status === 'succeeded') + } catch (error) { + console.error('Payment method validation error:', error) + return false + } +} +``` + +### 4. Trial Cancellation + +**File**: `app/api/trial/cancel/route.ts` + +```typescript +export async function POST(request: NextRequest) { + const token = await getToken({ req: request }) + const userId = token.sub + + const subscriptionData = await redis.hgetall(KEYS.USER_SUBSCRIPTION(userId)) + const subscriptionId = subscriptionData.stripeSubscriptionId + + if (!subscriptionId) { + return NextResponse.json( + { error: 'No subscription found' }, + { status: 404 } + ) + } + + const subscription = await stripe.subscriptions.retrieve(subscriptionId) + + if (subscription.status === 'trialing') { + // Immediate cancellation for trials + await stripe.subscriptions.cancel(subscriptionId, { + prorate: false + }) + + // Mark as trial cancellation + await redis.hset(KEYS.USER_CREDITS(userId), { + trialCanceled: 'true' + }) + + return NextResponse.json({ + success: true, + canceled: true, + creditsRemoved: true, + message: 'Trial canceled successfully' + }) + } else { + // Cancel at period end for active subscriptions + await stripe.subscriptions.update(subscriptionId, { + cancel_at_period_end: true + }) + + const periodEnd = new Date(subscription.current_period_end * 1000) + + return NextResponse.json({ + success: true, + canceledAtPeriodEnd: true, + accessUntil: periodEnd.toISOString(), + message: 'Subscription will cancel at period end' + }) + } +} +``` + +## Trial States + +### State Management + +```typescript +interface TrialState { + status: 'trialing' | 'active' | 'canceled' + trialStart?: string // ISO timestamp + trialEnd?: string // ISO timestamp + hasPaymentMethod: boolean + creditsGranted: boolean + autoConvertEnabled: boolean +} +``` + +### State Transitions + +```mermaid +graph TD + A[Start Trial] --> B{Payment Auth?} + B -->|Success| C[Trialing] + B -->|Failed| D[No Access] + C --> E{7 Days Later} + E -->|Auto-Convert| F[Active Subscription] + E -->|User Cancels| G[Canceled] + C -->|User Cancels| G + F -->|User Cancels| H[Cancel at Period End] + G --> I[Access Removed] + H --> I +``` + +### Status Synchronization + +**Redis Storage**: +```typescript +// user:${userId}:subscription +{ + status: 'trialing', + displayStatus: 'trialing', + stripeSubscriptionStatus: 'trialing', + trialStartDate: '2024-01-15T10:30:00.000Z', + trialEndDate: '2024-01-22T10:30:00.000Z' +} +``` + +**Stripe to Redis Mapping**: +```typescript +const statusMapping = { + 'trialing': { status: 'trialing', displayStatus: 'Trial' }, + 'active': { status: 'active', displayStatus: 'Active' }, + 'canceled': { status: 'canceled', displayStatus: 'Canceled' }, + 'past_due': { status: 'past_due', displayStatus: 'Past Due' } +} +``` + +## Webhook Integration + +### Trial Conversion Webhook + +**Event**: `invoice.payment_succeeded` + +```typescript +// When trial ends and first payment succeeds +if (invoice.billing_reason === 'subscription_create') { + await redis.hset(KEYS.USER_SUBSCRIPTION(userId), { + status: 'active', + stripeSubscriptionStatus: 'active', + displayStatus: 'active', + trialToActiveDate: new Date().toISOString(), + trialEndedAt: new Date().toISOString() + }) + + // Refresh credits for new active status + await subscriptionUtils.refreshCreditsIfNeeded(userId) +} +``` + +### Trial Cancellation Webhook + +**Event**: `customer.subscription.deleted` + +```typescript +await redis.hset(KEYS.USER_SUBSCRIPTION(userId), { + status: 'canceled', + stripeSubscriptionStatus: 'canceled', + displayStatus: 'canceled', + canceledAt: new Date().toISOString(), + subscriptionDeleted: 'true' +}) + +// Zero out credits for canceled trials +await creditUtils.setZeroCredits(userId) +``` + +## Payment Authorization + +### Stripe Configuration + +```typescript +// No immediate charge during trial +const subscription = await stripe.subscriptions.create({ + customer: customerId, + items: [{ price: priceId }], + trial_period_days: 7, + + // Payment method will be charged after trial + payment_behavior: 'default_incomplete', + expand: ['latest_invoice.payment_intent'] +}) +``` + +### Authorization Validation + +```typescript +// Ensure payment method is authorized before granting access +const paymentIntent = subscription.latest_invoice?.payment_intent + +if (paymentIntent?.status !== 'succeeded') { + throw new Error('Payment authorization required') +} +``` + +## Credit Management + +### Trial Credit Allocation + +```typescript +// Initialize 500 credits for trial users +await creditUtils.initializeTrialCredits(userId, { + total: 500, + resetDate: CreditConfig.getNextResetDate(), + isTrialCredits: 'true', + paymentIntentId: session.payment_intent.id +}) +``` + +### Credit Preservation Logic + +```typescript +// Credits preserved during successful trial conversion +// Only zeroed for genuine cancellations + +function shouldZeroCredits(creditsData: any): boolean { + // Genuine trial cancellation (user canceled before payment) + if (creditsData.trialCanceled === 'true') return true + + // Subscription deleted (not converted to paid) + if (creditsData.subscriptionDeleted === 'true') return true + + return false +} +``` + +## Frontend Integration + +### Trial Start Button + +```typescript +// components/pricing/TrialButton.tsx +function TrialButton({ priceId, planType }: TrialButtonProps) { + const [isLoading, setIsLoading] = useState(false) + + const startTrial = async () => { + setIsLoading(true) + + try { + // Create checkout session + const response = await fetch('/api/checkout-sessions', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ priceId, planType }) + }) + + const { url } = await response.json() + + // Redirect to Stripe checkout + window.location.href = url + } catch (error) { + console.error('Trial start failed:', error) + setIsLoading(false) + } + } + + return ( + + ) +} +``` + +### Checkout Success Handler + +```typescript +// app/checkout/success/page.tsx +'use client' + +export default function CheckoutSuccess() { + const searchParams = useSearchParams() + const sessionId = searchParams.get('session_id') + + useEffect(() => { + if (sessionId) { + startTrial(sessionId) + } + }, [sessionId]) + + const startTrial = async (sessionId: string) => { + try { + const response = await fetch('/api/trial/start', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ sessionId }) + }) + + if (response.ok) { + router.push('/chat?from=trial&refresh=true') + } else { + const error = await response.json() + console.error('Trial activation failed:', error) + } + } catch (error) { + console.error('Trial start error:', error) + } + } +} +``` + +## Error Handling + +### Common Error Scenarios + +1. **Payment Method Declined** + ```typescript + // Webhook: payment_intent.payment_failed + // Action: Prevent trial access, show error message + ``` + +2. **Checkout Session Expired** + ```typescript + // API Response: 400 Bad Request + // Message: "Checkout session expired" + ``` + +3. **Trial Already Started** + ```typescript + // API Response: 400 Bad Request + // Message: "Trial already active for this user" + ``` + +4. **3D Secure Required** + ```typescript + // Webhook: payment_intent.requires_action + // Action: Redirect user to complete authentication + ``` + +### Error Recovery + +```typescript +// Automatic retry for failed payment authorization +const retryPaymentSetup = async (customerId: string) => { + const setupIntent = await stripe.setupIntents.create({ + customer: customerId, + payment_method_types: ['card'], + usage: 'off_session' + }) + + return setupIntent.client_secret +} +``` + +## Testing + +### Test Scenarios + +1. **Successful Trial Flow** + ```bash + # Use test card: 4242424242424242 + # Expected: Trial access granted immediately + ``` + +2. **Declined Payment** + ```bash + # Use test card: 4000000000000002 + # Expected: Trial access denied + ``` + +3. **3D Secure Authentication** + ```bash + # Use test card: 4000002500003155 + # Expected: Additional authentication required + ``` + +4. **Trial Cancellation** + ```bash + # Cancel within 7 days + # Expected: Immediate access removal, no charge + ``` + +5. **Trial Conversion** + ```bash + # Wait 7 days (or use Stripe CLI) + # Expected: Automatic payment, continued access + ``` + +### Stripe CLI Testing + +```bash +# Trigger trial end event +stripe trigger invoice.payment_succeeded \ + --add invoice:billing_reason=subscription_create + +# Trigger trial cancellation +stripe trigger customer.subscription.deleted +``` + +## Monitoring + +### Key Metrics + +- **Trial Conversion Rate**: % of trials that convert to paid +- **Trial Cancellation Rate**: % of trials canceled before conversion +- **Payment Authorization Success**: % of successful payment setups +- **Time to Trial Start**: Average time from signup to trial access + +### Logging + +```typescript +// Trial events logging +console.log('Trial started:', { + userId, + trialEndDate, + creditsGranted: 500, + paymentMethodVerified: true +}) + +console.log('Trial converted:', { + userId, + conversionDate: new Date().toISOString(), + totalTrialDays: 7 +}) + +console.log('Trial canceled:', { + userId, + cancellationDate: new Date().toISOString(), + daysCanceled: daysIntoTrial +}) +``` + +## Security Considerations + +### Payment Authorization Validation +- Always verify payment method exists before granting access +- Validate Stripe webhook signatures +- Check session completion status +- Prevent duplicate trial activations + +### User Access Control +- Trial access only with valid subscription +- Credit limits enforced server-side +- Session-based authentication required +- No client-side access control bypass + +### Data Protection +- Encrypt sensitive payment data +- Log security events +- Monitor for unusual patterns +- Secure API endpoints with rate limiting \ No newline at end of file diff --git a/auto-analyst-frontend/docs/webhooks.md b/auto-analyst-frontend/docs/webhooks.md new file mode 100644 index 00000000..272e5db2 --- /dev/null +++ b/auto-analyst-frontend/docs/webhooks.md @@ -0,0 +1,433 @@ +# Webhooks + +This document covers all Stripe webhooks implemented in Auto-Analyst, their purposes, and data synchronization logic. + +## Overview + +Auto-Analyst uses Stripe webhooks to maintain real-time synchronization between Stripe and our Redis database. All webhooks are processed through a single endpoint: `/api/webhooks/route.ts` + +## Webhook Configuration + +### Endpoint URL +``` +https://your-domain.com/api/webhooks +``` + +### Required Events +The following events must be configured in your Stripe dashboard: + +```typescript +const requiredEvents = [ + 'checkout.session.completed', + 'customer.subscription.updated', + 'customer.subscription.deleted', + 'customer.subscription.trial_will_end', + 'invoice.payment_succeeded', + 'invoice.payment_failed', + 'payment_intent.payment_failed', + 'payment_intent.canceled', + 'setup_intent.setup_failed', + 'payment_intent.requires_action' +] +``` + +## Webhook Security + +### Signature Verification +```typescript +export async function POST(request: NextRequest) { + const signature = request.headers.get('stripe-signature') + + if (!signature) { + return NextResponse.json({ error: 'No Stripe signature found' }, { status: 400 }) + } + + const rawBody = await getRawBody(request.body as unknown as Readable) + + try { + event = stripe.webhooks.constructEvent(rawBody, signature, webhookSecret) + } catch (err: any) { + console.error(`⚠️ Webhook signature verification failed.`, err.message) + return NextResponse.json({ error: `Webhook Error: ${err.message}` }, { status: 400 }) + } + + // Process event... +} +``` + +### Environment Variables +```bash +STRIPE_WEBHOOK_SECRET=whsec_... # From Stripe Dashboard +``` + +## Webhook Events + +### 1. `checkout.session.completed` + +**Purpose**: Logs successful checkout completion (legacy - now handled by trial flow) + +**Processing Logic**: +```typescript +case 'checkout.session.completed': { + 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 }) +} +``` + +**Redis Updates**: None (logging only) + +### 2. `customer.subscription.updated` + +**Purpose**: Synchronizes subscription status changes + +**Processing Logic**: +```typescript +case 'customer.subscription.updated': { + const subscription = event.data.object as Stripe.Subscription + const userId = await getUserIdFromCustomerId(subscription.customer as string) + + const updateData: any = { + status: subscription.status, + lastUpdated: new Date().toISOString(), + stripeSubscriptionStatus: subscription.status + } + + // Handle specific status transitions + if (currentStatus === 'trialing' && subscription.status === 'active') { + updateData.trialEndedAt = new Date().toISOString() + updateData.trialToActiveDate = new Date().toISOString() + } + + if (subscription.status === 'canceled') { + updateData.canceledAt = new Date().toISOString() + } + + await redis.hset(KEYS.USER_SUBSCRIPTION(userId), updateData) + break +} +``` + +**Redis Updates**: +- Subscription status +- Status transition timestamps +- Stripe synchronization data + +### 3. `customer.subscription.deleted` + +**Purpose**: Handles subscription cancellation and cleanup + +**Processing Logic**: +```typescript +case 'customer.subscription.deleted': { + const subscription = event.data.object as Stripe.Subscription + const userId = await getUserIdFromCustomerId(subscription.customer as string) + + // Set credits to 0 immediately when subscription is canceled + await creditUtils.setZeroCredits(userId) + + // Update subscription data to reflect cancellation + await redis.hset(KEYS.USER_SUBSCRIPTION(userId), { + plan: 'No Active Plan', + planType: 'NONE', + status: 'canceled', + amount: '0', + interval: 'month', + lastUpdated: now.toISOString(), + canceledAt: now.toISOString(), + stripeCustomerId: '', + stripeSubscriptionId: '' + }) + + // Mark credits as subscription deleted + await redis.hset(KEYS.USER_CREDITS(userId), { + total: '0', + used: '0', + resetDate: '', + lastUpdate: now.toISOString(), + subscriptionDeleted: 'true' + }) + + break +} +``` + +**Redis Updates**: +- Subscription status → 'canceled' +- Credits → 0 +- Plan → 'No Active Plan' +- Cleanup Stripe IDs + +### 4. `customer.subscription.trial_will_end` + +**Purpose**: Logs upcoming trial expiration (for potential email notifications) + +**Processing Logic**: +```typescript +case 'customer.subscription.trial_will_end': { + const subscription = event.data.object as Stripe.Subscription + const userId = await getUserIdFromCustomerId(subscription.customer as string) + + // Optional: Send reminder email about trial ending + // You can add email notification logic here + + return NextResponse.json({ received: true }) +} +``` + +**Redis Updates**: None (logging only) + +### 5. `invoice.payment_succeeded` + +**Purpose**: Handles successful payments and trial conversions + +**Processing Logic**: +```typescript +case 'invoice.payment_succeeded': { + const invoice = event.data.object as Stripe.Invoice + + if (invoice.subscription) { + const subscription = await stripe.subscriptions.retrieve(invoice.subscription as string) + const userId = await getUserIdFromCustomerId(subscription.customer as string) + + if (invoice.billing_reason === 'subscription_cycle') { + // Trial ended, first payment successful + await redis.hset(KEYS.USER_SUBSCRIPTION(userId), { + status: 'active', + lastUpdated: new Date().toISOString(), + trialEndedAt: new Date().toISOString(), + lastPaymentDate: new Date().toISOString(), + stripeSubscriptionStatus: subscription.status + }) + } else if (invoice.billing_reason === 'subscription_create') { + // Initial subscription creation payment + await redis.hset(KEYS.USER_SUBSCRIPTION(userId), { + status: subscription.status, + lastUpdated: new Date().toISOString(), + initialPaymentDate: new Date().toISOString(), + stripeSubscriptionStatus: subscription.status + }) + } + } + + return NextResponse.json({ received: true }) +} +``` + +**Redis Updates**: +- Status activation +- Payment timestamps +- Trial conversion tracking + +### 6. `invoice.payment_failed` + +**Purpose**: Handles failed recurring payments + +**Processing Logic**: +```typescript +case 'invoice.payment_failed': { + const invoice = event.data.object as Stripe.Invoice + + if (invoice.subscription && invoice.billing_reason === 'subscription_cycle') { + const subscription = await stripe.subscriptions.retrieve(invoice.subscription as string) + const userId = await getUserIdFromCustomerId(subscription.customer as string) + + // 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() + }) + } + + return NextResponse.json({ received: true }) +} +``` + +**Redis Updates**: +- Status → 'past_due' +- Credits → 0 +- Payment failure timestamp + +### 7. Payment Protection Events + +These events prevent trial access when payment authorization fails: + +#### `payment_intent.payment_failed` +```typescript +case 'payment_intent.payment_failed': { + const paymentIntent = event.data.object as Stripe.PaymentIntent + + if (paymentIntent.metadata?.isTrial === 'true') { + const userId = paymentIntent.metadata?.userId + + if (userId) { + // Prevent trial access by ensuring credits remain at 0 + await creditUtils.setZeroCredits(userId) + + 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' + }) + } + } + break +} +``` + +#### `payment_intent.canceled` +```typescript +case 'payment_intent.canceled': { + const paymentIntent = event.data.object as Stripe.PaymentIntent + + if (paymentIntent.metadata?.isTrial === 'true') { + const userId = paymentIntent.metadata?.userId + + if (userId) { + await creditUtils.setZeroCredits(userId) + + await redis.hset(KEYS.USER_SUBSCRIPTION(userId), { + status: 'canceled', + lastUpdated: new Date().toISOString(), + canceledAt: new Date().toISOString(), + cancelReason: 'Payment intent canceled during trial signup' + }) + } + } + break +} +``` + +#### `setup_intent.setup_failed` +```typescript +case 'setup_intent.setup_failed': { + const setupIntent = event.data.object as Stripe.SetupIntent + + if (setupIntent.metadata?.is_trial_setup === 'true') { + const subscriptionId = setupIntent.metadata?.subscription_id + + if (subscriptionId) { + const subscription = await stripe.subscriptions.retrieve(subscriptionId) + const userId = await getUserIdFromCustomerId(subscription.customer as string) + + // Cancel the trial subscription since setup failed + await stripe.subscriptions.cancel(subscriptionId) + + // Ensure user doesn't get trial access + await creditUtils.setZeroCredits(userId) + + 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' + }) + } + } + break +} +``` + +#### `payment_intent.requires_action` +```typescript +case 'payment_intent.requires_action': { + const paymentIntent = event.data.object as Stripe.PaymentIntent + + 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 + } + break +} +``` + +## Helper Functions + +### User ID Resolution +```typescript +async function getUserIdFromCustomerId(customerId: string): Promise { + try { + const userId = await redis.get(`stripe:customer:${customerId}`) + return userId + } catch (error) { + console.error('Error getting userId from Redis:', error) + return null + } +} +``` + +### Error Handling +```typescript +try { + // Process webhook event + await processWebhookEvent(event) + return NextResponse.json({ received: true }) +} catch (error: any) { + console.error('Webhook error:', error) + return NextResponse.json({ error: error.message || 'Webhook handler failed' }, { status: 500 }) +} +``` + +## Webhook Testing + +### Local Development +```bash +# Install Stripe CLI +stripe listen --forward-to localhost:3000/api/webhooks + +# Trigger test events +stripe trigger customer.subscription.updated +stripe trigger invoice.payment_succeeded +``` + +### Test Event Data +```typescript +// Mock subscription update +{ + "id": "evt_test_webhook", + "object": "event", + "type": "customer.subscription.updated", + "data": { + "object": { + "id": "sub_test123", + "customer": "cus_test123", + "status": "active", + "trial_end": 1643723400 + } + } +} +``` + +## Monitoring + +### Webhook Delivery +- Check Stripe Dashboard → Webhooks → Endpoint logs +- Monitor delivery success/failure rates +- Review retry attempts + +### Error Tracking +```typescript +// Log all webhook events for debugging +console.log('Webhook received:', { + type: event.type, + id: event.id, + created: event.created, + livemode: event.livemode +}) +``` + +## Best Practices + +1. **Idempotency**: Handle duplicate events gracefully +2. **Fast Response**: Return 200 quickly, process asynchronously if needed +3. **Error Handling**: Return 5xx for retriable errors, 4xx for permanent failures +4. **Logging**: Log all events for debugging and monitoring +5. **Validation**: Verify event structure before processing +6. **Security**: Always verify webhook signatures +7. **Data Consistency**: Use atomic operations for Redis updates \ No newline at end of file diff --git a/auto-analyst-frontend/lib/README-Credits.md b/auto-analyst-frontend/lib/README-Credits.md index 1aef88ad..6c787a74 100644 --- a/auto-analyst-frontend/lib/README-Credits.md +++ b/auto-analyst-frontend/lib/README-Credits.md @@ -95,7 +95,7 @@ const shouldWarn = CreditConfig.shouldWarnLowCredits(85, 100) // true (85% usage ``` ## Files Updated - +git The following files have been updated to use the centralized configuration: ### Frontend @@ -105,7 +105,6 @@ The following files have been updated to use the centralized configuration: ### Backend APIs - `app/api/user/data/route.ts` - User data retrieval - `app/api/update-credits/route.ts` - Credit updates after payment -- `app/api/user/downgrade-plan/route.ts` - Plan downgrades - `app/api/webhooks/route.ts` - Stripe webhook handling - `app/api/user/cancel-subscription/route.ts` - Subscription cancellation - `app/api/initialize-credits/route.ts` - Credit initialization diff --git a/auto-analyst-frontend/lib/credits-config.ts b/auto-analyst-frontend/lib/credits-config.ts index 6d4afb55..8b84ce38 100644 --- a/auto-analyst-frontend/lib/credits-config.ts +++ b/auto-analyst-frontend/lib/credits-config.ts @@ -49,9 +49,9 @@ export interface TrialConfig { * Trial period configuration - Change here to update across the entire app */ export const TRIAL_CONFIG: TrialConfig = { - duration: 5, + duration: 10, unit: 'minutes', - displayText: '5-Minute Trial', + displayText: '10-Minute Trial', credits: 500 } diff --git a/auto-analyst-frontend/lib/redis.ts b/auto-analyst-frontend/lib/redis.ts index b704b334..3efb7ffb 100644 --- a/auto-analyst-frontend/lib/redis.ts +++ b/auto-analyst-frontend/lib/redis.ts @@ -212,10 +212,10 @@ export const subscriptionUtils = { const creditsData = await redis.hgetall(KEYS.USER_CREDITS(userId)); // Default values using centralized config - const defaultCredits = CreditConfig.getCreditsForPlan('Free') + const defaultCredits = CreditConfig.getCreditsByType('STANDARD') let plan = defaultCredits.displayName; let isPro = false; - let creditsTotal = defaultCredits.total; + let creditsTotal = 0; // No more free credits let creditsUsed = 0; // Parse subscription data if found @@ -248,13 +248,12 @@ export const subscriptionUtils = { } catch (error) { console.error('Error getting user subscription data:', error); // Return fallback defaults using centralized config - const defaultCredits = CreditConfig.getCreditsForPlan('Free') return { - plan: defaultCredits.displayName, + plan: 'No Plan', credits: { used: 0, - total: defaultCredits.total, - remaining: defaultCredits.total + total: 0, + remaining: 0 }, isPro: false }; @@ -266,16 +265,9 @@ export const subscriptionUtils = { try { const subscriptionData = await redis.hgetall(KEYS.USER_SUBSCRIPTION(userId)); - // Check if this is a Free plan (missing data is treated as Free) - const isFree = - !subscriptionData || - !subscriptionData.planType || - subscriptionData.planType === 'FREE' || - (subscriptionData.plan && (subscriptionData.plan as string).includes('Free')); - - // Free plans are always considered active - if (isFree) { - return true; + // No free plans anymore - users without valid subscription are inactive + if (!subscriptionData || !subscriptionData.planType || !subscriptionData.status) { + return false; } // For paid plans, check status and expiration