feat: Polar.sh payment integration, replace Shopier store approach
This commit is contained in:
@@ -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
@@ -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;
|
||||
};
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user