diff --git a/src/app/api/polar/create-checkout/route.ts b/src/app/api/polar/create-checkout/route.ts index 07f6e531..c7eba1ac 100644 --- a/src/app/api/polar/create-checkout/route.ts +++ b/src/app/api/polar/create-checkout/route.ts @@ -1,8 +1,7 @@ import { NextRequest, NextResponse } from "next/server"; import { getUser } from "@/lib/stack-auth"; +import { getPolarClient } from "@/lib/polar"; -// NOTE: Polar checkout will be implemented after Stack Auth is fully configured -// This is a placeholder route for now export async function POST(req: NextRequest) { try { // Authenticate user with Stack Auth @@ -15,11 +14,31 @@ export async function POST(req: NextRequest) { ); } - // TODO: Implement Polar checkout once Stack Auth is configured with proper API keys - return NextResponse.json( - { error: "Polar checkout not yet configured. Please set up Stack Auth first." }, - { status: 501 } - ); + const body = await req.json(); + const { productId } = body; + + if (!productId) { + return NextResponse.json( + { error: "Missing productId" }, + { status: 400 } + ); + } + + const polar = getPolarClient(); + const appUrl = process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000"; + + // Create checkout + // We pass userId in metadata so we can link subscription to user in webhook + const checkout = await polar.checkouts.create({ + productId, + successUrl: `${appUrl}/dashboard?checkout=success`, + customerEmail: user.primaryEmail || undefined, + metadata: { + userId: user.id, + }, + }); + + return NextResponse.json({ url: checkout.url }); } catch (error) { console.error("Error creating Polar checkout session:", error); diff --git a/src/app/api/webhooks/polar/route.ts b/src/app/api/webhooks/polar/route.ts new file mode 100644 index 00000000..9cec3c1f --- /dev/null +++ b/src/app/api/webhooks/polar/route.ts @@ -0,0 +1,106 @@ +import { NextRequest, NextResponse } from "next/server"; +import { validateEvent } from "@polar-sh/sdk/webhooks"; +import { ConvexHttpClient } from "convex/browser"; +import { api } from "@/convex/_generated/api"; +import { getPolarClient } from "@/lib/polar"; + +const convex = new ConvexHttpClient(process.env.NEXT_PUBLIC_CONVEX_URL!); + +export async function POST(req: NextRequest) { + const requestBody = await req.text(); + const webhookHeaders: Record = {}; + + req.headers.forEach((value, key) => { + webhookHeaders[key] = value; + }); + + const webhookSecret = process.env.POLAR_WEBHOOK_SECRET; + + if (!webhookSecret) { + console.error("POLAR_WEBHOOK_SECRET is missing"); + return NextResponse.json({ error: "Configuration error" }, { status: 500 }); + } + + let event; + try { + event = validateEvent(requestBody, webhookHeaders, webhookSecret); + } catch (error) { + console.error("Webhook verification failed:", error); + return NextResponse.json({ error: "Invalid signature" }, { status: 400 }); + } + + try { + switch (event.type) { + case "subscription.created": + case "subscription.updated": + case "subscription.active": { + const subscription = event.data; + const userId = subscription.metadata?.userId as string | undefined; + + if (!userId) { + console.warn(`Subscription ${subscription.id} missing userId in metadata`); + return NextResponse.json({ received: true }); + } + + // Try to get product name from payload or fetch it + let productName = (subscription as any).product?.name; + + if (!productName) { + try { + const polar = getPolarClient(); + // Polar SDK types might differ, but usually there's a way to get product + const product = await polar.products.get({ id: subscription.product_id }); + productName = product.name; + } catch (e) { + console.error("Failed to fetch product details", e); + productName = "Subscription"; + } + } + + // Map Polar status to Convex status + let status: "incomplete" | "active" | "canceled" | "past_due" | "unpaid" = "active"; + const s = subscription.status; + + if (["active", "trialing"].includes(s)) status = "active"; + else if (["incomplete"].includes(s)) status = "incomplete"; + else if (["past_due"].includes(s)) status = "past_due"; + else if (["canceled", "incomplete_expired"].includes(s)) status = "canceled"; + else if (["unpaid"].includes(s)) status = "unpaid"; + + await convex.mutation(api.subscriptions.createOrUpdateSubscription, { + userId: userId, + polarCustomerId: subscription.customer_id, + polarSubscriptionId: subscription.id, + productId: subscription.product_id, + productName: productName, + status: status, + currentPeriodStart: new Date(subscription.current_period_start).getTime(), + currentPeriodEnd: new Date(subscription.current_period_end).getTime(), + cancelAtPeriodEnd: subscription.cancel_at_period_end, + metadata: subscription.metadata, + }); + break; + } + + case "subscription.revoked": + await convex.mutation(api.subscriptions.revokeSubscription, { + polarSubscriptionId: event.data.id, + }); + break; + + case "subscription.canceled": + await convex.mutation(api.subscriptions.markSubscriptionForCancellation, { + polarSubscriptionId: event.data.id, + }); + break; + + default: + console.log(`Unhandled event type: ${event.type}`); + } + } catch (error) { + console.error("Error processing webhook:", error); + return NextResponse.json({ error: "Error processing webhook" }, { status: 500 }); + } + + return NextResponse.json({ received: true }); +} diff --git a/src/lib/polar.ts b/src/lib/polar.ts new file mode 100644 index 00000000..995126ee --- /dev/null +++ b/src/lib/polar.ts @@ -0,0 +1,18 @@ +import { Polar } from "@polar-sh/sdk"; + +let polarInstance: Polar | null = null; + +export const getPolarClient = () => { + if (!polarInstance) { + const accessToken = process.env.POLAR_ACCESS_TOKEN; + if (!accessToken) { + throw new Error("POLAR_ACCESS_TOKEN is missing"); + } + + polarInstance = new Polar({ + accessToken, + server: process.env.NODE_ENV === "development" ? "sandbox" : "production", + }); + } + return polarInstance; +};