chore: update iyzico integration and enhance payment processing logic
All checks were successful
Deploy / deploy (push) Successful in 7m0s
All checks were successful
Deploy / deploy (push) Successful in 7m0s
Refactored the iyzico payment integration to improve the handling of payment callbacks and order status updates. Added signature validation for responses to ensure data integrity. Enhanced the `initializeCheckoutForm` function to include buyer's name and surname, and updated the order schema to enforce unique constraints on iyzico tokens. Improved error handling and logging for payment processing, ensuring better tracking of payment states and issues. Updated the app database to reflect these changes.
This commit is contained in:
@@ -1,54 +1,182 @@
|
||||
import "server-only";
|
||||
import { createHmac, timingSafeEqual } from "node:crypto";
|
||||
import Iyzipay from "iyzipay";
|
||||
|
||||
// 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 };
|
||||
|
||||
function getClient(): Iyzipay {
|
||||
if (!process.env.IYZICO_API_KEY || !process.env.IYZICO_SECRET_KEY) {
|
||||
throw new Error("IYZICO_KEYS_MISSING");
|
||||
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: process.env.IYZICO_API_KEY,
|
||||
secretKey: process.env.IYZICO_SECRET_KEY,
|
||||
uri: process.env.IYZICO_BASE_URL ?? "https://sandbox-api.iyzipay.com",
|
||||
});
|
||||
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;
|
||||
conversationId?: string;
|
||||
signature?: string;
|
||||
errorCode?: string;
|
||||
errorMessage?: string;
|
||||
}
|
||||
|
||||
export interface CheckoutFormSonuc {
|
||||
status: string;
|
||||
paymentStatus?: string; // "SUCCESS" beklenir
|
||||
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: "KolayTercih Kullanıcısı",
|
||||
contactName: `${opts.ad} ${opts.soyad}`.trim(),
|
||||
city: "Istanbul",
|
||||
country: "Turkey",
|
||||
address: "Dijital teslimat",
|
||||
@@ -65,8 +193,8 @@ export function initializeCheckoutForm(opts: {
|
||||
enabledInstallments: [1],
|
||||
buyer: {
|
||||
id: opts.userId,
|
||||
name: "KolayTercih",
|
||||
surname: "Kullanıcısı",
|
||||
name: opts.ad,
|
||||
surname: opts.soyad,
|
||||
gsmNumber: "+905000000000",
|
||||
email: opts.email,
|
||||
identityNumber: "11111111111",
|
||||
@@ -75,6 +203,8 @@ export function initializeCheckoutForm(opts: {
|
||||
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: [
|
||||
@@ -98,9 +228,12 @@ export function initializeCheckoutForm(opts: {
|
||||
});
|
||||
}
|
||||
|
||||
export function retrieveCheckoutForm(
|
||||
token: string,
|
||||
): Promise<CheckoutFormSonuc> {
|
||||
/**
|
||||
* 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,
|
||||
@@ -110,4 +243,4 @@ export function retrieveCheckoutForm(
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user