Skip to content
Closed
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
33 changes: 26 additions & 7 deletions src/app/api/polar/create-checkout/route.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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);

Expand Down
106 changes: 106 additions & 0 deletions src/app/api/webhooks/polar/route.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> = {};

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(),
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Invalid date conversion without null checks in webhook

The webhook handler converts subscription.current_period_start and subscription.current_period_end to timestamps without validating they exist or are valid date values. If these fields are undefined or null, new Date() creates an Invalid Date and .getTime() returns NaN. This NaN value is then passed to the Convex mutation, which expects valid numbers, resulting in corrupted subscription records. The webhook returns successfully (200 OK) despite the data corruption, masking the error from Polar's retry mechanism.

Fix in Cursor Fix in Web

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,
});
Comment on lines +91 to +94

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Cancel webhook leaves subscription active

In the Polar webhook handler the subscription.canceled event routes to markSubscriptionForCancellation, which only sets the cancelAtPeriodEnd flag (see convex/subscriptions.ts) and leaves the status untouched. When Polar fires this event after a subscription is actually canceled, our Convex record will stay at its previous status (often active), so entitlement checks will never see the cancellation and users retain access. The canceled event should update the subscription status (e.g., via createOrUpdateSubscription or revokeSubscription) rather than just scheduling cancellation.

Useful? React with 👍 / 👎.

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 });
}
18 changes: 18 additions & 0 deletions src/lib/polar.ts
Original file line number Diff line number Diff line change
@@ -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;
};
Loading