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 —
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 { 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 { 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); }, ); }); }