feat: Polar.sh payment integration, replace Shopier store approach

This commit is contained in:
kovakmedya
2026-05-04 17:45:30 +03:00
parent f43818a51a
commit e8a766c60a
9 changed files with 395 additions and 342 deletions
+93
View File
@@ -0,0 +1,93 @@
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 saldırısı koruması (5 dakika tolerans)
const ts = parseInt(webhookTimestamp, 10);
if (isNaN(ts) || Math.abs(Date.now() / 1000 - ts) > 300) return false;
const signedContent = `${webhookId}.${webhookTimestamp}.${rawBody}`;
// Secret base64 olabilir
let secretBytes: Buffer;
try {
secretBytes = Buffer.from(WEBHOOK_SECRET.replace(/^whsec_/, ""), "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;
}
});
}
+41 -109
View File
@@ -2,126 +2,58 @@ import "server-only";
import { createHmac, timingSafeEqual } from "crypto";
export const SHOPIER_ENDPOINT = "https://www.shopier.com/ShowProduct/api_pay4.php";
// Shopier Developer Portal > App > Webhooks bölümünden alınan token.
const WEBHOOK_TOKEN = process.env.SHOPIER_WEBHOOK_TOKEN ?? "";
const API_KEY = process.env.SHOPIER_API_KEY ?? "";
const API_SECRET = process.env.SHOPIER_API_SECRET ?? "";
const WEBSITE_INDEX = process.env.SHOPIER_WEBSITE_INDEX ?? "1";
// Her plan için Shopier mağazasındaki ürün URL'i.
const PLAN_URLS: Record<string, string> = {
pro: process.env.SHOPIER_STORE_PRO_URL ?? "",
};
// Callback base URL can be overridden with a tunnel URL for localhost testing.
// e.g. SHOPIER_CALLBACK_BASE_URL=https://abc123.trycloudflare.com
function callbackBaseUrl(): string {
export function isShopierEnabled(): boolean {
return (
process.env.SHOPIER_CALLBACK_BASE_URL?.replace(/\/$/, "") ??
process.env.APP_URL?.replace(/\/$/, "") ??
"http://localhost:3000"
process.env.PAYMENT_PROVIDER === "shopier" &&
Boolean(WEBHOOK_TOKEN) &&
Boolean(PLAN_URLS.pro)
);
}
function appBaseUrl(): string {
return process.env.APP_URL?.replace(/\/$/, "") ?? "http://localhost:3000";
export function getShopierPlanUrl(plan: string): string {
return PLAN_URLS[plan] ?? "";
}
export type CurrencyCode = "TRY" | "USD" | "EUR";
const CURRENCY_INDEX: Record<CurrencyCode, "0" | "1" | "2"> = {
TRY: "0",
USD: "1",
EUR: "2",
};
// Signature over: platform_order_id + total_order_value + currency + random_nr
// Key: API_SECRET | Encoding: base64
function signRequest(orderId: string, amount: string, currency: string, randomNr: string): string {
const message = orderId + amount + currency + randomNr;
return createHmac("sha256", API_SECRET).update(message).digest("base64");
}
export type ShopierCheckoutParams = {
orderId: string;
amount: number;
currency?: CurrencyCode;
productName: string;
buyerName: string;
buyerSurname: string;
buyerEmail: string;
buyerId: string;
buyerAccountAgeDays?: number;
};
export type ShopierFormFields = Record<string, string>;
export function buildShopierFormFields(params: ShopierCheckoutParams): ShopierFormFields {
if (!API_KEY || !API_SECRET) {
throw new Error("SHOPIER_API_KEY ve SHOPIER_API_SECRET ortam değişkenleri eksik.");
}
const currency: CurrencyCode = params.currency ?? "TRY";
const currencyIndex = CURRENCY_INDEX[currency];
// Amount as string with exactly 2 decimal places ("299.00")
const amountStr = params.amount.toFixed(2);
// 8-digit random number
const randomNr = String(Math.floor(10000000 + Math.random() * 90000000));
const signature = signRequest(params.orderId, amountStr, currencyIndex, randomNr);
const base = callbackBaseUrl();
const appBase = appBaseUrl();
return {
API_key: API_KEY,
website_index: WEBSITE_INDEX,
platform_order_id: params.orderId,
product_name: params.productName,
product_type: "0", // 0 = digital/virtual
buyer_name: params.buyerName,
buyer_surname: params.buyerSurname,
buyer_email: params.buyerEmail,
buyer_account_age: String(params.buyerAccountAgeDays ?? 0),
buyer_id_nr: params.buyerId,
total_order_value: amountStr,
currency: currencyIndex,
current_language: "0", // 0 = TR
random_nr: randomNr,
signature,
// Server-to-server async callback — needs public URL (use cloudflared tunnel for localhost)
callbackUrl: `${base}/api/payments/shopier/callback`,
// User-facing redirect after 3DS
okUrl: `${appBase}/settings/billing/checkout/${params.orderId}/shopier/result?ok=1`,
failUrl: `${appBase}/settings/billing/checkout/${params.orderId}/shopier/result?ok=0`,
};
}
// ---------- Callback verification ----------
export type ShopierCallbackPayload = {
platform_order_id: string;
status: string; // "success" | "fail"
installment_count: string;
random_nr: string;
API_key: string;
signature: string;
};
// Signature over: platform_order_id + status + installment_count + random_nr
export function verifyCallbackSignature(payload: ShopierCallbackPayload): boolean {
if (!API_SECRET) return false;
const message =
payload.platform_order_id +
payload.status +
payload.installment_count +
payload.random_nr;
const expected = createHmac("sha256", API_SECRET).update(message).digest("base64");
// Shopier HS256 imzasını doğrular.
// İmza formatı: HMAC-SHA256(webhook_token, raw_body) → base64
// Eğer ilk gerçek webhook'ta uyuşmazlık olursa hex formatını deneyin:
// .digest("hex") yerine .digest("base64")
export function verifyShopierWebhookSignature(signature: string, rawBody: string): boolean {
if (!WEBHOOK_TOKEN) return false;
const expected = createHmac("sha256", WEBHOOK_TOKEN).update(rawBody).digest("base64");
try {
return timingSafeEqual(Buffer.from(expected), Buffer.from(payload.signature));
return timingSafeEqual(Buffer.from(expected), Buffer.from(signature));
} catch {
return false;
}
}
export function isShopierEnabled(): boolean {
return (
process.env.PAYMENT_PROVIDER === "shopier" &&
Boolean(process.env.SHOPIER_API_KEY) &&
Boolean(process.env.SHOPIER_API_SECRET)
);
}
// order.created webhook payload (Shopier Order modeli alt kümesi)
export type ShopierWebhookOrder = {
id: string;
status: string; // "fulfilled" | "unfulfilled"
paymentStatus: string; // "paid" | "unpaid"
dateCreated: string;
currency: string;
totals: {
total: string;
subtotal: string;
discount: string;
shipping: string;
};
shippingInfo: {
email: string;
firstName: string;
lastName: string;
phone?: string;
company?: string;
};
};