Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"terminal.integrated.sendKeybindingsToShell": true
}
1 change: 0 additions & 1 deletion docker-compose.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
version: '3.8'

services:
# ================================
Expand Down
3 changes: 2 additions & 1 deletion src/app/(auth)/sign-in/[[...sign-in]]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,11 @@ export default function Page() {
appearance={{
baseTheme: dark
}}
redirectUrl="/"
/>
</div>
<div className='block dark:hidden'>
<SignIn />
<SignIn redirectUrl="/" />
</div>
</div>
);
Expand Down
34 changes: 23 additions & 11 deletions src/app/(dashboard)/generate-pro/page.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,25 @@
import Generate from "@/components/core/generate";
import FlashcardPro from "@/components/core/flash-card-pro";
import { auth } from '@clerk/nextjs/server';
import { subscriptionService } from '@/lib/services/subscription.service';
import { redirect } from 'next/navigation';

export default function GeneratePage() {
return (
// <div className="page-container flex items-center min-h-screen">
// <h1 className='text-3xl text-foreground font-semibold'>Generate Flashcards</h1>
// <Generate />
// </div>
<div className="min-h-screen">
<Generate />
</div>
);
export default async function GenerateProPage() {
const { userId } = await auth();

if (!userId) {
redirect('/sign-in?redirect_url=/generate-pro');
}

// Check if user has Pro access
const canAccessPro = await subscriptionService.canAccessProFeatures(userId);

if (!canAccessPro) {
redirect('/pricing?upgrade=required&redirect=/generate-pro');
}

return (
<div className="page-container bg-slate-50 dark:bg-black">
<FlashcardPro />
</div>
);
}
73 changes: 11 additions & 62 deletions src/app/api/checkout-sessions/route.ts
Original file line number Diff line number Diff line change
@@ -1,65 +1,14 @@
import { NextResponse } from "next/server";
import Stripe from "stripe";

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: "2024-06-20",
});

export async function POST(req: Request) {
const { subscriptionType } = await req.json();

const priceId =
subscriptionType === "yearly"
? process.env.STRIPE_PRICE_YEARLY
: process.env.STRIPE_PRICE_MONTHLY;

if (!priceId) {
return NextResponse.json(
{ error: "Invalid subscription type" },
{ status: 400 },
);
}

try {
const session = await stripe.checkout.sessions.create({
mode: "subscription",
payment_method_types: ["card"],
line_items: [
{
price: priceId,
quantity: 1,
},
],
success_url: `${process.env.NEXT_PUBLIC_APP_URL}/result?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${process.env.NEXT_PUBLIC_APP_URL}/pricing`,
});

return NextResponse.json({ sessionId: session.id });
} catch (error) {
console.error("Error creating checkout session:", error);
return NextResponse.json(
{ error: "Error creating checkout session" },
{ status: 500 },
);
}
// Stripe support removed in favor of Razorpay.
export async function POST() {
return new Response(JSON.stringify({ error: 'Stripe removed. Use Razorpay.' }), {
status: 410,
headers: { 'Content-Type': 'application/json' }
})
}

export async function GET(req: Request) {
const { searchParams } = new URL(req.url);
const session_id = searchParams.get("session_id");

if (!session_id) {
return NextResponse.json({ error: "Missing session_id" }, { status: 400 });
}

try {
const session = await stripe.checkout.sessions.retrieve(session_id);
return NextResponse.json(session);
} catch (error) {
console.error("Error retrieving checkout session:", error);
return NextResponse.json(
{ error: "Error retrieving checkout session" },
{ status: 500 },
);
}
export async function GET() {
return new Response(JSON.stringify({ error: 'Stripe removed. Use Razorpay.' }), {
status: 410,
headers: { 'Content-Type': 'application/json' }
})
}
40 changes: 39 additions & 1 deletion src/app/api/generate/route.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { NextResponse } from "next/server";
import { auth } from "@clerk/nextjs/server";
import { flashcardService } from "@/lib/services/flashcard.service";
import { prisma } from "@/lib/database";
import { subscriptionService } from "@/lib/services/subscription.service";
import type { ApiError, FlashcardGenerationResponse } from "@/types";

export async function POST(req: Request) {
Expand All @@ -19,7 +21,43 @@ export async function POST(req: Request) {
return NextResponse.json(error, { status: 400 });
}

// ✅ CORRECT: Single Responsibility - Delegate to service
// Determine plan-based limits
const status = await subscriptionService.getUserSubscriptionStatus(userId);
// Monthly flashcard caps (per saved/generated content)
const monthlyCaps: Record<string, number> = {
free: 10,
basic: 500,
pro: 2000,
orgs: 10000,
};
const planKey = status.plan?.toLowerCase?.() || 'free';
const monthlyCap = monthlyCaps[planKey] ?? 10;

// Calculate current month's usage based on saved flashcards
const now = new Date();
const startOfMonth = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1, 0, 0, 0));

const usedThisMonth = await prisma.flashcard.count({
where: {
userId,
createdAt: { gte: startOfMonth },
},
});

const remainingThisMonth = Math.max(0, monthlyCap - usedThisMonth);
if (remainingThisMonth <= 0) {
const error: ApiError = {
error: "Monthly limit reached",
details: `You've reached your monthly limit of ${monthlyCap} flashcards for the ${planKey} plan.`
};
return NextResponse.json(error, { status: 403 });
}

// Always generate at most 10 at a time, and never exceed remaining monthly allowance
const PER_REQUEST_CAP = 10;
const desiredCount = Math.min(PER_REQUEST_CAP, remainingThisMonth);

// ✅ CORRECT: Single Responsibility - Delegate to service with count
const flashcards = await flashcardService.generateFlashcards(text);

const response: FlashcardGenerationResponse = { flashcards };
Expand Down
184 changes: 184 additions & 0 deletions src/app/api/razorpay/webhook/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
import { NextRequest, NextResponse } from 'next/server';
import crypto from 'crypto';
import { prisma } from '@/lib/database';

// Ensure this route is always handled at runtime and not during build
export const dynamic = 'force-dynamic';
export const runtime = 'nodejs';

// Webhook secret from Razorpay dashboard
// Note: Do NOT throw at module init; check inside the handler to avoid build-time failures

export async function POST(req: NextRequest) {
console.log('🔔 Razorpay webhook received');

try {
const WEBHOOK_SECRET = process.env.RAZORPAY_WEBHOOK_SECRET;
if (!WEBHOOK_SECRET) {
console.error('❌ Missing RAZORPAY_WEBHOOK_SECRET');
return NextResponse.json({ error: 'Missing webhook secret' }, { status: 500 });
}

const body = await req.text();
const signature = req.headers.get('x-razorpay-signature');

if (!signature) {
console.log('❌ No signature found in webhook');
return NextResponse.json({ error: 'No signature' }, { status: 400 });
}

// Verify webhook signature
const expectedSignature = crypto
.createHmac('sha256', WEBHOOK_SECRET)
.update(body)
.digest('hex');

if (signature !== expectedSignature) {
console.log('❌ Invalid webhook signature');
return NextResponse.json({ error: 'Invalid signature' }, { status: 400 });
}

console.log('✅ Webhook signature verified');

const event = JSON.parse(body);
console.log('📦 Webhook event:', event.event);

// Handle different payment events
switch (event.event) {
case 'payment.captured':
await handlePaymentCaptured(event.payload.payment.entity);
break;

case 'payment.failed':
await handlePaymentFailed(event.payload.payment.entity);
break;

case 'order.paid':
await handleOrderPaid(event.payload.order.entity);
break;

default:
console.log('ℹ️ Unhandled webhook event:', event.event);
}

return NextResponse.json({ success: true });

} catch (error) {
console.error('💥 Webhook error:', error);
return NextResponse.json({ error: 'Webhook processing failed' }, { status: 500 });
}
}

async function handlePaymentCaptured(payment: any) {
console.log('💰 Payment captured:', payment.id);

try {
// Find the payment record
const paymentRecord = await prisma.payment.findFirst({
where: { razorpayPaymentId: payment.id }
});

if (!paymentRecord) {
console.log('❌ Payment record not found for:', payment.id);
return;
}

// Update payment status
if (paymentRecord.status === 'COMPLETED') {
console.log('⚠️ Payment already processed:', payment.id);
return;
}
await prisma.payment.update({
where: { id: paymentRecord.id },
data: {
status: 'COMPLETED',
razorpayPaymentId: payment.id,
updatedAt: new Date()
}
});

// Get order details to determine subscription
const order = await prisma.payment.findFirst({
where: { razorpayOrderId: payment.order_id }
});

if (order) {
// Calculate subscription dates
const now = new Date();
const subscriptionStartedAt = new Date(now);
const subscriptionEndsAt = new Date(now);
const daysToAdd = order.billingCycle === 'monthly' ? 30 : 365;
subscriptionEndsAt.setDate(subscriptionEndsAt.getDate() + daysToAdd);

// Update user subscription
await prisma.user.update({
where: { clerkUserId: order.userId },
data: {
subscriptionPlan: order.plan,
subscriptionCycle: order.billingCycle,
subscriptionStatus: 'active',
paymentId: payment.id,
subscriptionEndsAt: subscriptionEndsAt,
subscriptionStartedAt: subscriptionStartedAt,
updatedAt: new Date()
}
});

console.log('✅ User subscription activated:', {
userId: order.userId,
plan: order.plan,
billingCycle: order.billingCycle
});
}

} catch (error) {
console.error('❌ Error handling payment captured:', error);
}
}

async function handlePaymentFailed(payment: any) {
console.log('❌ Payment failed:', payment.id);

try {
// Find and update payment record
const paymentRecord = await prisma.payment.findFirst({
where: { razorpayPaymentId: payment.id }
});

if (paymentRecord) {
await prisma.payment.update({
where: { id: paymentRecord.id },
data: {
status: 'FAILED',
failureReason: payment.error_description || 'Payment failed',
updatedAt: new Date()
}
});

console.log('✅ Payment marked as failed:', payment.id);
}

} catch (error) {
console.error('❌ Error handling payment failed:', error);
}
}

async function handleOrderPaid(order: any) {
console.log('✅ Order paid:', order.id);

try {
// Update order status
await prisma.payment.updateMany({
where: { razorpayOrderId: order.id },
data: {
status: 'COMPLETED',
updatedAt: new Date()
}
});

console.log('✅ Order marked as paid:', order.id);

} catch (error) {
console.error('❌ Error handling order paid:', error);
}
}
Loading