95 lines
2.7 KiB
TypeScript
95 lines
2.7 KiB
TypeScript
import "server-only";
|
||
|
||
import { createHmac, timingSafeEqual } from "crypto";
|
||
|
||
const POLAR_API_BASE = "https://api.polar.sh";
|
||
const ACCESS_TOKEN = process.env.POLAR_ACCESS_TOKEN ?? "";
|
||
const WEBHOOK_SECRET = process.env.POLAR_WEBHOOK_SECRET ?? "";
|
||
|
||
export const POLAR_PRODUCT_ID = process.env.POLAR_PRODUCT_ID ?? "";
|
||
|
||
export function isPolarEnabled(): boolean {
|
||
return (
|
||
process.env.PAYMENT_PROVIDER === "polar" &&
|
||
Boolean(ACCESS_TOKEN) &&
|
||
Boolean(POLAR_PRODUCT_ID)
|
||
);
|
||
}
|
||
|
||
export type PolarCheckout = {
|
||
id: string;
|
||
url: string;
|
||
};
|
||
|
||
export async function createPolarCheckout(params: {
|
||
orderId: string;
|
||
tenantId: string;
|
||
userEmail: string;
|
||
successUrl: string;
|
||
}): Promise<PolarCheckout> {
|
||
const res = await fetch(`${POLAR_API_BASE}/v1/checkouts/`, {
|
||
method: "POST",
|
||
headers: {
|
||
Authorization: `Bearer ${ACCESS_TOKEN}`,
|
||
"Content-Type": "application/json",
|
||
},
|
||
body: JSON.stringify({
|
||
products: [POLAR_PRODUCT_ID],
|
||
customer_email: params.userEmail,
|
||
success_url: params.successUrl,
|
||
metadata: {
|
||
crm_order_id: params.orderId,
|
||
tenant_id: params.tenantId,
|
||
},
|
||
}),
|
||
});
|
||
|
||
if (!res.ok) {
|
||
const text = await res.text();
|
||
throw new Error(`Polar checkout oluşturulamadı: ${text}`);
|
||
}
|
||
|
||
return res.json() as Promise<PolarCheckout>;
|
||
}
|
||
|
||
// Standard Webhooks imza doğrulama
|
||
// Header: webhook-id, webhook-timestamp, webhook-signature
|
||
// Signed content: "{webhook-id}.{webhook-timestamp}.{rawBody}"
|
||
// Signature: "v1," + base64(HMAC-SHA256(secret, signedContent))
|
||
export function verifyPolarWebhook(
|
||
webhookId: string,
|
||
webhookTimestamp: string,
|
||
webhookSignature: string,
|
||
rawBody: string,
|
||
): boolean {
|
||
if (!WEBHOOK_SECRET) return false;
|
||
|
||
// Timestamp replay koruması (1 saat — Polar retry aralığı uzun olabilir)
|
||
const ts = parseInt(webhookTimestamp, 10);
|
||
if (isNaN(ts) || Math.abs(Date.now() / 1000 - ts) > 3600) return false;
|
||
|
||
const signedContent = `${webhookId}.${webhookTimestamp}.${rawBody}`;
|
||
|
||
// Polar secret: "polar_whs_<base64>" — prefix soyulup base64 decode edilir
|
||
let secretBytes: Buffer;
|
||
try {
|
||
const raw = WEBHOOK_SECRET.replace(/^(whsec_|polar_whs_)/, "");
|
||
secretBytes = Buffer.from(raw, "base64");
|
||
} catch {
|
||
secretBytes = Buffer.from(WEBHOOK_SECRET);
|
||
}
|
||
|
||
const expected = createHmac("sha256", secretBytes).update(signedContent).digest("base64");
|
||
const expectedFull = `v1,${expected}`;
|
||
|
||
// Header birden fazla imza içerebilir (space ile ayrılmış)
|
||
const signatures = webhookSignature.split(" ");
|
||
return signatures.some((sig) => {
|
||
try {
|
||
return timingSafeEqual(Buffer.from(expectedFull), Buffer.from(sig));
|
||
} catch {
|
||
return false;
|
||
}
|
||
});
|
||
}
|