88 lines
2.9 KiB
TypeScript
88 lines
2.9 KiB
TypeScript
|
|
import { NextRequest, NextResponse } from 'next/server'
|
||
|
|
import { prisma } from '@/lib/prisma'
|
||
|
|
import { createHmac } from 'crypto'
|
||
|
|
|
||
|
|
// Polar product IDs → tier flags
|
||
|
|
const PRODUCT_TIERS: Record<string, { isPremium: boolean; isPro: boolean }> = {
|
||
|
|
'a291a3d9-fa82-4e88-ba89-c6005401398f': { isPremium: true, isPro: false }, // Premium
|
||
|
|
'7f983017-e1f3-440e-be2a-5a5d19c9c7b0': { isPremium: true, isPro: true }, // Pro
|
||
|
|
}
|
||
|
|
|
||
|
|
function verifySignature(body: string, headers: Headers): boolean {
|
||
|
|
const secret = process.env.POLAR_WEBHOOK_SECRET
|
||
|
|
if (!secret) return false
|
||
|
|
|
||
|
|
// Polar uses Svix: signature is over "id.timestamp.body"
|
||
|
|
const webhookId = headers.get('webhook-id')
|
||
|
|
const timestamp = headers.get('webhook-timestamp')
|
||
|
|
const signature = headers.get('webhook-signature')
|
||
|
|
if (!webhookId || !timestamp || !signature) return false
|
||
|
|
|
||
|
|
const signedContent = `${webhookId}.${timestamp}.${body}`
|
||
|
|
const secretBytes = Buffer.from(secret.replace('whsec_', ''), 'base64')
|
||
|
|
const computed = createHmac('sha256', secretBytes).update(signedContent).digest('base64')
|
||
|
|
|
||
|
|
// Signature header may contain multiple: "v1,<sig1> v1,<sig2>"
|
||
|
|
return signature.split(' ').some(s => s === `v1,${computed}`)
|
||
|
|
}
|
||
|
|
|
||
|
|
export async function POST(req: NextRequest) {
|
||
|
|
const body = await req.text()
|
||
|
|
|
||
|
|
if (process.env.POLAR_WEBHOOK_SECRET && !verifySignature(body, req.headers)) {
|
||
|
|
return NextResponse.json({ error: 'Invalid signature' }, { status: 400 })
|
||
|
|
}
|
||
|
|
|
||
|
|
let event: { type: string; data: any }
|
||
|
|
try {
|
||
|
|
event = JSON.parse(body)
|
||
|
|
} catch {
|
||
|
|
return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 })
|
||
|
|
}
|
||
|
|
|
||
|
|
const { type, data } = event
|
||
|
|
|
||
|
|
try {
|
||
|
|
switch (type) {
|
||
|
|
case 'subscription.created':
|
||
|
|
case 'subscription.updated': {
|
||
|
|
const userId = data.metadata?.userId || data.customer?.metadata?.userId
|
||
|
|
if (!userId) break
|
||
|
|
|
||
|
|
const productId = data.product_id || data.product?.id
|
||
|
|
const tier = productId ? PRODUCT_TIERS[productId] : null
|
||
|
|
const isActive = ['active', 'trialing'].includes(data.status)
|
||
|
|
|
||
|
|
if (isActive && tier) {
|
||
|
|
await prisma.user.update({
|
||
|
|
where: { id: userId },
|
||
|
|
data: tier,
|
||
|
|
})
|
||
|
|
} else if (!isActive) {
|
||
|
|
await prisma.user.update({
|
||
|
|
where: { id: userId },
|
||
|
|
data: { isPremium: false, isPro: false },
|
||
|
|
})
|
||
|
|
}
|
||
|
|
break
|
||
|
|
}
|
||
|
|
|
||
|
|
case 'subscription.revoked':
|
||
|
|
case 'subscription.canceled': {
|
||
|
|
const userId = data.metadata?.userId || data.customer?.metadata?.userId
|
||
|
|
if (!userId) break
|
||
|
|
await prisma.user.update({
|
||
|
|
where: { id: userId },
|
||
|
|
data: { isPremium: false, isPro: false },
|
||
|
|
})
|
||
|
|
break
|
||
|
|
}
|
||
|
|
}
|
||
|
|
} catch (e) {
|
||
|
|
console.error('Polar webhook handler error:', e)
|
||
|
|
return NextResponse.json({ error: 'Handler error' }, { status: 500 })
|
||
|
|
}
|
||
|
|
|
||
|
|
return NextResponse.json({ received: true })
|
||
|
|
}
|