Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
192 changes: 192 additions & 0 deletions src/app/api/razorpay/webhook/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
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 (constant-time)
const expectedSignature = crypto
.createHmac('sha256', WEBHOOK_SECRET)
.update(body)
.digest('hex');

const providedBuf = Buffer.from(signature, 'hex');
const expectedBuf = Buffer.from(expectedSignature, 'hex');

if (providedBuf.length !== expectedBuf.length || !crypto.timingSafeEqual(providedBuf, expectedBuf)) {
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 (fallback by order id)
const paymentRecord = await prisma.payment.findFirst({
where: {
OR: [
{ razorpayPaymentId: payment.id },
{ razorpayOrderId: payment.order_id }
]
}
});

if (paymentRecord) {
await prisma.payment.updateMany({
where: { id: paymentRecord.id, status: { not: 'COMPLETED' } },
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