de48918acf
- Webhook polar/route: block direct ?userId=&tier= upgrades in production (NODE_ENV guard) - Seed route: require ADMIN_SECRET via x-admin-secret header - Feedback route: require FEEDBACK_ADMIN_TOKEN env var in production - CORS middleware: add Vary: Origin header for caching correctness - MOCK_PAYMENTS: add NODE_ENV production guard - Wallet top-up: add production guard for mock mode when POLAR_ACCESS_TOKEN unset
399 lines
11 KiB
TypeScript
399 lines
11 KiB
TypeScript
import { NextRequest, NextResponse } from "next/server";
|
|
import crypto from "crypto";
|
|
import { prisma } from "@/lib/prisma";
|
|
|
|
/**
|
|
* Polar.sh webhook handler.
|
|
*
|
|
* In production, verify the webhook signature using Polar's webhook secret.
|
|
* For development, we accept a direct ?userId=&tier= fallback for testing.
|
|
*/
|
|
|
|
export async function POST(req: NextRequest) {
|
|
try {
|
|
// Check for direct fallback query params (dev/testing only)
|
|
const url = new URL(req.url);
|
|
const directUserId = url.searchParams.get("userId");
|
|
const directTier = url.searchParams.get("tier");
|
|
|
|
if (directUserId && directTier) {
|
|
// Production guard: never allow direct upgrades in production
|
|
if (process.env.NODE_ENV === "production") {
|
|
console.error("Direct upgrade blocked — not allowed in production");
|
|
return NextResponse.json(
|
|
{ error: "Direct upgrades not allowed in production" },
|
|
{ status: 403 }
|
|
);
|
|
}
|
|
return handleDirectUpgrade(directUserId, directTier);
|
|
}
|
|
|
|
// Production path: Parse Polar webhook event
|
|
const rawBody = await req.text();
|
|
|
|
// Verify webhook signature
|
|
const signature = req.headers.get("webhook-id") || req.headers.get("polar-signature");
|
|
const webhookSecret = process.env.POLAR_WEBHOOK_SECRET;
|
|
|
|
if (webhookSecret) {
|
|
if (!signature) {
|
|
console.error("Polar webhook signature missing");
|
|
return NextResponse.json(
|
|
{ error: "Missing webhook signature" },
|
|
{ status: 401 }
|
|
);
|
|
}
|
|
|
|
const expectedSignature = crypto
|
|
.createHmac("sha256", webhookSecret)
|
|
.update(rawBody)
|
|
.digest("hex");
|
|
|
|
if (signature !== expectedSignature) {
|
|
console.error("Polar webhook signature mismatch");
|
|
return NextResponse.json(
|
|
{ error: "Invalid webhook signature" },
|
|
{ status: 401 }
|
|
);
|
|
}
|
|
} else {
|
|
console.warn("POLAR_WEBHOOK_SECRET not set — skipping signature verification (dev mode)");
|
|
}
|
|
|
|
const body = JSON.parse(rawBody);
|
|
const event = body.type || body.event;
|
|
|
|
if (!event) {
|
|
return NextResponse.json(
|
|
{ error: "Missing event type" },
|
|
{ status: 400 }
|
|
);
|
|
}
|
|
|
|
// Handle checkout.completed event
|
|
if (
|
|
event === "checkout.completed" ||
|
|
event === "checkout.updated"
|
|
) {
|
|
const checkout = body.data?.checkout || body.data;
|
|
if (!checkout) {
|
|
return NextResponse.json(
|
|
{ error: "Missing checkout data" },
|
|
{ status: 400 }
|
|
);
|
|
}
|
|
|
|
const metadataType = checkout.metadata?.type;
|
|
const customerEmail = checkout.customer?.email;
|
|
|
|
// --- Course purchase flow ---
|
|
if (metadataType === "course_purchase") {
|
|
const courseSlug = checkout.metadata?.course_slug;
|
|
if (!courseSlug) {
|
|
return NextResponse.json(
|
|
{ error: "Missing course_slug in metadata for course_purchase" },
|
|
{ status: 400 }
|
|
);
|
|
}
|
|
if (!customerEmail) {
|
|
return NextResponse.json(
|
|
{ error: "Missing customer email for course_purchase" },
|
|
{ status: 400 }
|
|
);
|
|
}
|
|
return handleCoursePurchase(customerEmail, courseSlug);
|
|
}
|
|
|
|
// --- Learn subscription flow ---
|
|
if (metadataType === "learn_subscription") {
|
|
if (!customerEmail) {
|
|
return NextResponse.json(
|
|
{ error: "Missing customer email for learn_subscription" },
|
|
{ status: 400 }
|
|
);
|
|
}
|
|
return handleLearnSubscription(customerEmail, checkout);
|
|
}
|
|
|
|
// --- Fallback: premium/pro upgrade (existing logic) ---
|
|
const userId = checkout.metadata?.userId;
|
|
const priceId = checkout.product?.priceId ||
|
|
checkout.productPrice?.id ||
|
|
checkout.metadata?.priceId;
|
|
|
|
if (!userId) {
|
|
return NextResponse.json(
|
|
{ error: "Missing userId in metadata" },
|
|
{ status: 400 }
|
|
);
|
|
}
|
|
|
|
// Determine tier from priceId or product metadata
|
|
let tier: "premium" | "pro" = "premium";
|
|
if (priceId?.includes("pro") || checkout.metadata?.tier === "pro") {
|
|
tier = "pro";
|
|
}
|
|
|
|
return applyUpgrade(userId, tier);
|
|
}
|
|
|
|
// Handle subscription.cancelled and subscription.revoked events
|
|
if (event === "subscription.cancelled" || event === "subscription.revoked") {
|
|
return handleSubscriptionCancelled(body.data);
|
|
}
|
|
|
|
// Acknowledge other events silently
|
|
return NextResponse.json({ received: true });
|
|
} catch (error) {
|
|
console.error("Polar webhook error:", error);
|
|
return NextResponse.json(
|
|
{ error: "Webhook processing failed" },
|
|
{ status: 500 }
|
|
);
|
|
}
|
|
}
|
|
|
|
async function handleDirectUpgrade(userId: string, tier: string) {
|
|
if (tier !== "premium" && tier !== "pro") {
|
|
return NextResponse.json(
|
|
{ error: "Invalid tier. Must be 'premium' or 'pro'." },
|
|
{ status: 400 }
|
|
);
|
|
}
|
|
return applyUpgrade(userId, tier as "premium" | "pro");
|
|
}
|
|
|
|
async function applyUpgrade(userId: string, tier: "premium" | "pro") {
|
|
const user = await prisma.user.findUnique({ where: { id: userId } });
|
|
if (!user) {
|
|
return NextResponse.json({ error: "User not found" }, { status: 404 });
|
|
}
|
|
|
|
const trialEndsAt = new Date();
|
|
trialEndsAt.setDate(trialEndsAt.getDate() + 7);
|
|
|
|
const updateData: Record<string, unknown> = {
|
|
trialEndsAt,
|
|
};
|
|
|
|
if (tier === "pro") {
|
|
updateData.isPro = true;
|
|
updateData.isPremium = false; // Pro supersedes Premium
|
|
} else {
|
|
updateData.isPremium = true;
|
|
}
|
|
|
|
await prisma.user.update({
|
|
where: { id: userId },
|
|
data: updateData,
|
|
});
|
|
|
|
return NextResponse.json({
|
|
success: true,
|
|
tier,
|
|
trialEndsAt,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Handle a course_purchase checkout.
|
|
* Looks up the user by email, finds the LearnCourse by slug,
|
|
* and creates (or re-activates) a LearnEnrollment with purchaseType='one_time'.
|
|
*/
|
|
async function handleCoursePurchase(customerEmail: string, courseSlug: string) {
|
|
// Find the user by email
|
|
const user = await prisma.user.findUnique({ where: { email: customerEmail } });
|
|
if (!user) {
|
|
return NextResponse.json(
|
|
{ error: "User not found for email" },
|
|
{ status: 404 }
|
|
);
|
|
}
|
|
|
|
// Find the course by slug
|
|
const course = await prisma.learnCourse.findUnique({ where: { slug: courseSlug } });
|
|
if (!course) {
|
|
return NextResponse.json(
|
|
{ error: "Course not found" },
|
|
{ status: 404 }
|
|
);
|
|
}
|
|
|
|
// Upsert enrollment: create or re-activate on re-purchase
|
|
await prisma.learnEnrollment.upsert({
|
|
where: {
|
|
userId_courseId: { userId: user.id, courseId: course.id },
|
|
},
|
|
create: {
|
|
userId: user.id,
|
|
courseId: course.id,
|
|
purchaseType: "one_time",
|
|
progress: 0,
|
|
startedAt: new Date(),
|
|
},
|
|
update: {
|
|
purchaseType: "one_time",
|
|
completed: false,
|
|
progress: 0,
|
|
completedAt: null,
|
|
startedAt: new Date(),
|
|
},
|
|
});
|
|
|
|
return NextResponse.json({
|
|
success: true,
|
|
type: "course_purchase",
|
|
userId: user.id,
|
|
courseId: course.id,
|
|
courseSlug,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Handle a learn_subscription checkout.
|
|
* Looks up the user by email and creates/updates a LearnSubscription record.
|
|
* Uses Polar subscription data for period management.
|
|
*/
|
|
async function handleLearnSubscription(customerEmail: string, checkout: any) {
|
|
// Find the user by email
|
|
const user = await prisma.user.findUnique({ where: { email: customerEmail } });
|
|
if (!user) {
|
|
return NextResponse.json(
|
|
{ error: "User not found for email" },
|
|
{ status: 404 }
|
|
);
|
|
}
|
|
|
|
// Extract subscription details from checkout data
|
|
const polarSubId =
|
|
checkout.subscription?.id ||
|
|
checkout.metadata?.subscriptionId ||
|
|
null;
|
|
|
|
const now = new Date();
|
|
const currentPeriodStart = checkout.subscription?.currentPeriodStart
|
|
? new Date(checkout.subscription.currentPeriodStart)
|
|
: now;
|
|
const currentPeriodEnd = checkout.subscription?.currentPeriodEnd
|
|
? new Date(checkout.subscription.currentPeriodEnd)
|
|
: new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000); // Default 30 days
|
|
|
|
// Upsert subscription record per user (one active subscription per user)
|
|
if (polarSubId) {
|
|
await prisma.learnSubscription.upsert({
|
|
where: { polarSubId },
|
|
create: {
|
|
userId: user.id,
|
|
polarSubId,
|
|
status: "active",
|
|
currentPeriodStart,
|
|
currentPeriodEnd,
|
|
},
|
|
update: {
|
|
userId: user.id,
|
|
status: "active",
|
|
currentPeriodStart,
|
|
currentPeriodEnd,
|
|
cancelledAt: null,
|
|
},
|
|
});
|
|
} else {
|
|
// No polarSubId: check if user already has a subscription and update it
|
|
const existing = await prisma.learnSubscription.findFirst({
|
|
where: { userId: user.id },
|
|
orderBy: { createdAt: "desc" },
|
|
});
|
|
|
|
if (existing) {
|
|
await prisma.learnSubscription.update({
|
|
where: { id: existing.id },
|
|
data: {
|
|
status: "active",
|
|
currentPeriodStart,
|
|
currentPeriodEnd,
|
|
cancelledAt: null,
|
|
},
|
|
});
|
|
} else {
|
|
await prisma.learnSubscription.create({
|
|
data: {
|
|
userId: user.id,
|
|
status: "active",
|
|
currentPeriodStart,
|
|
currentPeriodEnd,
|
|
},
|
|
});
|
|
}
|
|
}
|
|
|
|
// Also update the User's premium status to match the active subscription
|
|
await prisma.user.update({
|
|
where: { id: user.id },
|
|
data: {
|
|
isPremium: true,
|
|
trialEndsAt: currentPeriodEnd,
|
|
},
|
|
});
|
|
|
|
return NextResponse.json({
|
|
success: true,
|
|
type: "learn_subscription",
|
|
userId: user.id,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Handle a subscription.cancelled or subscription.revoked event.
|
|
* Sets the LearnSubscription status to 'cancelled' and removes the user's isPremium flag.
|
|
*/
|
|
async function handleSubscriptionCancelled(data: any) {
|
|
// Extract the Polar subscription ID from the event data
|
|
const polarSubId =
|
|
data?.id ||
|
|
data?.subscription?.id ||
|
|
data?.metadata?.subscriptionId ||
|
|
null;
|
|
|
|
if (!polarSubId) {
|
|
return NextResponse.json(
|
|
{ error: "Missing subscription ID in event data" },
|
|
{ status: 400 }
|
|
);
|
|
}
|
|
|
|
// Find the LearnSubscription by the Polar subscription ID
|
|
const subscription = await prisma.learnSubscription.findUnique({
|
|
where: { polarSubId },
|
|
});
|
|
|
|
if (!subscription) {
|
|
return NextResponse.json(
|
|
{ error: "LearnSubscription not found for polarSubId" },
|
|
{ status: 404 }
|
|
);
|
|
}
|
|
|
|
// Mark the subscription as cancelled
|
|
await prisma.learnSubscription.update({
|
|
where: { id: subscription.id },
|
|
data: {
|
|
status: "cancelled",
|
|
cancelledAt: new Date(),
|
|
},
|
|
});
|
|
|
|
// Remove the user's premium flag
|
|
await prisma.user.update({
|
|
where: { id: subscription.userId },
|
|
data: {
|
|
isPremium: false,
|
|
},
|
|
});
|
|
|
|
return NextResponse.json({
|
|
success: true,
|
|
type: "subscription_cancelled",
|
|
userId: subscription.userId,
|
|
});
|
|
}
|