Files
falah-mobile/src/app/api/webhooks/polar/route.ts
T
pi-agent de48918acf security: fix critical webhook direct upgrade path, seed auth, CORS Vary header
- 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
2026-06-28 01:15:45 +08:00

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,
});
}