feat: Shopier payment integration with 3DS callback + unified checkout action
This commit is contained in:
@@ -0,0 +1,127 @@
|
||||
import "server-only";
|
||||
|
||||
import { createHmac, timingSafeEqual } from "crypto";
|
||||
|
||||
export const SHOPIER_ENDPOINT = "https://www.shopier.com/ShowProduct/api_pay4.php";
|
||||
|
||||
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";
|
||||
|
||||
// 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 {
|
||||
return (
|
||||
process.env.SHOPIER_CALLBACK_BASE_URL?.replace(/\/$/, "") ??
|
||||
process.env.APP_URL?.replace(/\/$/, "") ??
|
||||
"http://localhost:3000"
|
||||
);
|
||||
}
|
||||
|
||||
function appBaseUrl(): string {
|
||||
return process.env.APP_URL?.replace(/\/$/, "") ?? "http://localhost:3000";
|
||||
}
|
||||
|
||||
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");
|
||||
try {
|
||||
return timingSafeEqual(Buffer.from(expected), Buffer.from(payload.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)
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user