Some checks failed
Deploy / deploy (push) Has been cancelled
outputFileTracingIncludes yalnızca eşleşen dosyaları kopyalar, trace etmez: resources/*.js imaja girdi ama IyzipayResource'un require ettiği postman-request ve bağımlılıkları girmedi — initialize prod'da "Cannot find module 'postman-request'" ile düşüyordu. IyzipayResource'u statik import etmek nft trace'inin tüm zinciri doğru pnpm yerleşimiyle kopyalamasını sağlar. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
255 lines
8.0 KiB
TypeScript
255 lines
8.0 KiB
TypeScript
import "server-only";
|
||
import { createHmac, timingSafeEqual } from "node:crypto";
|
||
import Iyzipay from "iyzipay";
|
||
// Yalnızca nft trace için: Iyzipay.js resource sınıflarını readdir ile dinamik
|
||
// yüklediğinden IyzipayResource ve onun require ettiği postman-request zinciri
|
||
// statik analizde görünmez, standalone imaja kopyalanmaz ve initialize prod'da
|
||
// MODULE_NOT_FOUND ile düşer. Bu import zinciri trace'e sokar; resources/*.js
|
||
// dosyalarını ise next.config.ts'teki outputFileTracingIncludes taşır.
|
||
import "iyzipay/lib/IyzipayResource";
|
||
|
||
// iyzipay CJS + callback tabanlı; burada promisify'lı ince bir katman var.
|
||
// Anahtarlar boşsa (henüz sandbox hesabı yoksa) çağrı anlaşılır bir hatayla düşer.
|
||
//
|
||
// Doğrulama: iyzico her yanıtta HMAC-SHA256 bir `signature` döner ve webhook'ta
|
||
// X-IYZ-SIGNATURE-V3 başlığı gönderir. Alan listeleri ve sıraları iyzico
|
||
// dokümantasyonundan birebir alınmıştır (docs.iyzico.com → Response Signature
|
||
// Validation / Webhook); değiştirilirse imzalar tutmaz.
|
||
|
||
const globalForIyzi = globalThis as unknown as { __iyzipay?: Iyzipay };
|
||
|
||
const SANDBOX_URI = "https://sandbox-api.iyzipay.com";
|
||
|
||
function anahtarlar() {
|
||
const apiKey = process.env.IYZICO_API_KEY;
|
||
const secretKey = process.env.IYZICO_SECRET_KEY;
|
||
if (!apiKey || !secretKey) throw new Error("IYZICO_KEYS_MISSING");
|
||
const uri = process.env.IYZICO_BASE_URL ?? SANDBOX_URI;
|
||
// Sessizce sandbox'a düşmek prod'da "ödeme alındı ama para yok" demektir;
|
||
// env unutulursa en azından log'da bağırsın.
|
||
if (process.env.NODE_ENV === "production" && uri === SANDBOX_URI) {
|
||
console.warn(
|
||
"[odeme] UYARI: prod'da iyzico sandbox URI kullanılıyor — IYZICO_BASE_URL ayarlanmamış",
|
||
);
|
||
}
|
||
return { apiKey, secretKey, uri };
|
||
}
|
||
|
||
function getClient(): Iyzipay {
|
||
const { apiKey, secretKey, uri } = anahtarlar();
|
||
if (!globalForIyzi.__iyzipay) {
|
||
globalForIyzi.__iyzipay = new Iyzipay({ apiKey, secretKey, uri });
|
||
}
|
||
return globalForIyzi.__iyzipay;
|
||
}
|
||
|
||
// ---- imza doğrulama ----
|
||
|
||
/**
|
||
* iyzico imzası: alanlar ":" ile birleştirilir, secretKey ile HMAC-SHA256'lanır
|
||
* ve hex'e çevrilir. Boş/eksik alan boş string olarak katılır (iyzico da öyle
|
||
* hesaplar), sıra kritiktir.
|
||
*/
|
||
function imzaHesapla(
|
||
alanlar: readonly (string | number | null | undefined)[],
|
||
secretKey: string,
|
||
) {
|
||
const veri = alanlar.map((a) => (a == null ? "" : String(a))).join(":");
|
||
return createHmac("sha256", secretKey).update(veri).digest("hex");
|
||
}
|
||
|
||
/** Sabit zamanlı karşılaştırma — uzunluk farkında timingSafeEqual patlar. */
|
||
function esitMi(a: string, b: string) {
|
||
const ab = Buffer.from(a, "utf8");
|
||
const bb = Buffer.from(b, "utf8");
|
||
return ab.length === bb.length && timingSafeEqual(ab, bb);
|
||
}
|
||
|
||
/**
|
||
* İmzada fiyatlar sondaki sıfırlar atılmış haliyle geçer: "10.50" → "10.5",
|
||
* "299.00" → "299". (iyzico'nun kendi örneği parseFloat(x).toString().)
|
||
*/
|
||
function fiyatSadelestir(p: string | number | undefined) {
|
||
if (p == null) return "";
|
||
const n = typeof p === "number" ? p : Number.parseFloat(p);
|
||
return Number.isFinite(n) ? String(n) : String(p);
|
||
}
|
||
|
||
export type ImzaDurumu = "gecerli" | "gecersiz" | "yok";
|
||
|
||
function imzaKarsilastir(
|
||
alanlar: readonly (string | number | null | undefined)[],
|
||
imza: string | undefined,
|
||
): ImzaDurumu {
|
||
if (!imza) return "yok";
|
||
const { secretKey } = anahtarlar();
|
||
return esitMi(imzaHesapla(alanlar, secretKey), imza) ? "gecerli" : "gecersiz";
|
||
}
|
||
|
||
// ---- checkout form ----
|
||
|
||
export interface CheckoutFormInitSonuc {
|
||
status: string;
|
||
token?: string;
|
||
paymentPageUrl?: string;
|
||
/** Kendi sayfamıza gömülen form script'i — <div id="iyzipay-checkout-form"> ister */
|
||
checkoutFormContent?: string;
|
||
conversationId?: string;
|
||
signature?: string;
|
||
errorCode?: string;
|
||
errorMessage?: string;
|
||
}
|
||
|
||
export interface CheckoutFormSonuc {
|
||
status: string;
|
||
paymentStatus?: string; // SUCCESS | FAILURE | INIT_THREEDS | PENDING_CREDIT ...
|
||
fraudStatus?: number; // 1 onaylı, 0 incelemede, -1 reddedildi
|
||
conversationId?: string;
|
||
basketId?: string;
|
||
paymentId?: string;
|
||
currency?: string;
|
||
price?: string | number;
|
||
paidPrice?: string | number;
|
||
token?: string;
|
||
signature?: string;
|
||
errorCode?: string;
|
||
errorMessage?: string;
|
||
}
|
||
|
||
/** initialize yanıtı imzası: conversationId:token */
|
||
export function initImzaDurumu(r: CheckoutFormInitSonuc): ImzaDurumu {
|
||
return imzaKarsilastir([r.conversationId, r.token], r.signature);
|
||
}
|
||
|
||
/**
|
||
* retrieve yanıtı imzası:
|
||
* paymentStatus:paymentId:currency:basketId:conversationId:paidPrice:price:token
|
||
*/
|
||
export function retrieveImzaDurumu(r: CheckoutFormSonuc): ImzaDurumu {
|
||
return imzaKarsilastir(
|
||
[
|
||
r.paymentStatus,
|
||
r.paymentId,
|
||
r.currency,
|
||
r.basketId,
|
||
r.conversationId,
|
||
fiyatSadelestir(r.paidPrice),
|
||
fiyatSadelestir(r.price),
|
||
r.token,
|
||
],
|
||
r.signature,
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Webhook X-IYZ-SIGNATURE-V3 (HPP formatı — checkout form bu formatta gelir).
|
||
* Anahtarın kendisi de veriye katılır:
|
||
* HMAC(secretKey, secretKey + iyziEventType + iyziPaymentId + token +
|
||
* paymentConversationId + status)
|
||
*/
|
||
export function webhookImzaDurumu(
|
||
govde: {
|
||
iyziEventType?: string;
|
||
iyziPaymentId?: string | number;
|
||
token?: string;
|
||
paymentConversationId?: string;
|
||
status?: string;
|
||
},
|
||
imza: string | undefined,
|
||
): ImzaDurumu {
|
||
if (!imza) return "yok";
|
||
const { secretKey } = anahtarlar();
|
||
const veri =
|
||
secretKey +
|
||
(govde.iyziEventType ?? "") +
|
||
(govde.iyziPaymentId ?? "") +
|
||
(govde.token ?? "") +
|
||
(govde.paymentConversationId ?? "") +
|
||
(govde.status ?? "");
|
||
const beklenen = createHmac("sha256", secretKey).update(veri).digest("hex");
|
||
return esitMi(beklenen, imza) ? "gecerli" : "gecersiz";
|
||
}
|
||
|
||
export function initializeCheckoutForm(opts: {
|
||
orderId: string;
|
||
fiyatKurus: number;
|
||
urunAdi: string;
|
||
email: string;
|
||
userId: string;
|
||
ad: string;
|
||
soyad: string;
|
||
callbackUrl: string;
|
||
buyerIp: string;
|
||
}): Promise<CheckoutFormInitSonuc> {
|
||
const price = (opts.fiyatKurus / 100).toFixed(2);
|
||
// iyzico buyer bloğu zorunlu alanlar ister; dijital üründe adres sembolik
|
||
const adres = {
|
||
contactName: `${opts.ad} ${opts.soyad}`.trim(),
|
||
city: "Istanbul",
|
||
country: "Turkey",
|
||
address: "Dijital teslimat",
|
||
};
|
||
const request = {
|
||
locale: Iyzipay.LOCALE.TR,
|
||
conversationId: opts.orderId,
|
||
price,
|
||
paidPrice: price,
|
||
currency: Iyzipay.CURRENCY.TRY,
|
||
basketId: opts.orderId,
|
||
paymentGroup: Iyzipay.PAYMENT_GROUP.PRODUCT,
|
||
callbackUrl: opts.callbackUrl,
|
||
enabledInstallments: [1],
|
||
buyer: {
|
||
id: opts.userId,
|
||
name: opts.ad,
|
||
surname: opts.soyad,
|
||
gsmNumber: "+905000000000",
|
||
email: opts.email,
|
||
identityNumber: "11111111111",
|
||
registrationAddress: adres.address,
|
||
ip: opts.buyerIp,
|
||
city: adres.city,
|
||
country: adres.country,
|
||
},
|
||
// Sepet tamamen VIRTUAL; iyzico shippingAddress'i bu durumda zorunlu
|
||
// tutmuyor ama göndermek de sorun değil, fraud skorunda tutarlılık sağlar.
|
||
shippingAddress: adres,
|
||
billingAddress: adres,
|
||
basketItems: [
|
||
{
|
||
id: opts.orderId,
|
||
name: opts.urunAdi,
|
||
category1: "Dijital Hizmet",
|
||
itemType: Iyzipay.BASKET_ITEM_TYPE.VIRTUAL,
|
||
price,
|
||
},
|
||
],
|
||
};
|
||
return new Promise((resolve, reject) => {
|
||
getClient().checkoutFormInitialize.create(
|
||
request as never,
|
||
(err: unknown, result: CheckoutFormInitSonuc) => {
|
||
if (err) reject(err);
|
||
else resolve(result);
|
||
},
|
||
);
|
||
});
|
||
}
|
||
|
||
/**
|
||
* conversationId BİLEREK gönderilmez: gönderilirse iyzico onu aynen yankılar ve
|
||
* "yanıttaki conversationId siparişimizle aynı mı" kontrolü anlamsızlaşır.
|
||
* Göndermeyince ödemenin kendi conversationId'si döner ve doğrulanabilir.
|
||
*/
|
||
export function retrieveCheckoutForm(token: string): Promise<CheckoutFormSonuc> {
|
||
return new Promise((resolve, reject) => {
|
||
getClient().checkoutForm.retrieve(
|
||
{ locale: Iyzipay.LOCALE.TR, token } as never,
|
||
(err: unknown, result: CheckoutFormSonuc) => {
|
||
if (err) reject(err);
|
||
else resolve(result);
|
||
},
|
||
);
|
||
});
|
||
}
|