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:
@@ -94,7 +94,13 @@ export const orders = sqliteTable(
|
||||
createdAt: integer("created_at", { mode: "timestamp" }).notNull(),
|
||||
paidAt: integer("paid_at", { mode: "timestamp" }),
|
||||
},
|
||||
(t) => [index("orders_user").on(t.userId)],
|
||||
(t) => [
|
||||
index("orders_user").on(t.userId),
|
||||
// Callback ve webhook siparişi token'dan bulur; UNIQUE aynı zamanda bir
|
||||
// token'ın iki siparişe bağlanmasını da imkânsız kılar (SQLite'ta çoklu
|
||||
// NULL serbesttir, token'sız pending siparişler etkilenmez).
|
||||
uniqueIndex("orders_iyzico_token").on(t.iyzicoToken),
|
||||
],
|
||||
);
|
||||
|
||||
export const creditLedger = sqliteTable(
|
||||
|
||||
@@ -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(
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,21 +1,59 @@
|
||||
import "server-only";
|
||||
import { after } from "next/server";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { and, eq, ne } from "drizzle-orm";
|
||||
import { appDb, schema } from "./appdb";
|
||||
import { grantCredits, URUNLER } from "./credits";
|
||||
import { retrieveCheckoutForm } from "./iyzico";
|
||||
import {
|
||||
retrieveCheckoutForm,
|
||||
retrieveImzaDurumu,
|
||||
type CheckoutFormSonuc,
|
||||
} from "./iyzico";
|
||||
import { sunucuOlayi } from "./analitik-sunucu";
|
||||
|
||||
const { orders } = schema;
|
||||
|
||||
export type SiparisDurumu = "paid" | "pending" | "failed" | "not_found";
|
||||
|
||||
/**
|
||||
* Ödeme henüz sonuçlanmamış ara durumlar (3DS ekranı, havale/kredi bekleyen
|
||||
* akışlar, fraud incelemesi). Bunlarda sipariş "failed" DAMGALANMAZ — damgalarsak
|
||||
* dakikalar sonra SUCCESS'e dönen ödeme "başarısız" kalır ve kredi tanımlanmaz.
|
||||
*/
|
||||
const ARA_DURUMLAR = new Set([
|
||||
"INIT_THREEDS",
|
||||
"CALLBACK_THREEDS",
|
||||
"BKM_POS_SELECTED",
|
||||
"INIT_APM",
|
||||
"INIT_CONTACTLESS",
|
||||
"INIT_BANK_TRANSFER",
|
||||
"INIT_CREDIT",
|
||||
"PENDING_CREDIT",
|
||||
]);
|
||||
|
||||
/** iyzico yanıtı krediyi tanımlamak için yeterli mi? */
|
||||
function karar(
|
||||
sonuc: CheckoutFormSonuc,
|
||||
): "paid" | "pending" | "failed" {
|
||||
if (sonuc.status !== "success") return "failed";
|
||||
if (sonuc.paymentStatus && ARA_DURUMLAR.has(sonuc.paymentStatus)) {
|
||||
return "pending";
|
||||
}
|
||||
if (sonuc.paymentStatus !== "SUCCESS") return "failed";
|
||||
// fraudStatus: 1 onaylı, 0 incelemede, -1 reddedildi. İncelemedeyken çekim
|
||||
// kesinleşmemiştir; krediyi webhook/yenileme SUCCESS+1 getirince tanımlarız.
|
||||
if (sonuc.fraudStatus === 0) return "pending";
|
||||
if (sonuc.fraudStatus === -1) return "failed";
|
||||
return "paid";
|
||||
}
|
||||
|
||||
/**
|
||||
* Token'la iyzico'dan sonucu çeker ve başarılıysa krediyi İDEMPOTENT tanımlar.
|
||||
* Hem callback route'u hem /odeme/sonuc self-healing fallback'i bunu kullanır.
|
||||
* Callback route'u, webhook ve /odeme/sonuc self-healing fallback'i bunu kullanır.
|
||||
* Dönen değer: siparişin son durumu.
|
||||
*/
|
||||
export async function odemeyiSonuclandir(
|
||||
orderId: string,
|
||||
): Promise<"paid" | "pending" | "failed" | "not_found"> {
|
||||
): Promise<SiparisDurumu> {
|
||||
const order = await appDb.query.orders.findFirst({
|
||||
where: eq(orders.id, orderId),
|
||||
});
|
||||
@@ -30,18 +68,55 @@ export async function odemeyiSonuclandir(
|
||||
return order.status; // iyzico'ya ulaşılamadı; durumu değiştirme
|
||||
}
|
||||
|
||||
if (sonuc.status !== "success" || sonuc.paymentStatus !== "SUCCESS") {
|
||||
// İmza tutmuyorsa yanıt bütünlüğü bozulmuş demektir — hiçbir şey yazma.
|
||||
// İmza alanı hiç yoksa (hesapta kapalıysa) yanıt zaten kimliği doğrulanmış
|
||||
// sunucu-sunucu çağrısından geldiği için akış sürer, sadece iz bırakılır.
|
||||
const imza = retrieveImzaDurumu(sonuc);
|
||||
if (imza === "gecersiz") {
|
||||
console.error("[odeme] iyzico imzası geçersiz", { orderId });
|
||||
return order.status;
|
||||
}
|
||||
// Hata yanıtlarında imza alanı zaten gelmez; yalnızca başarılı yanıtta eksikse
|
||||
// anlamlı bir sinyal (hesapta imza kapalı ya da API değişmiş).
|
||||
if (imza === "yok" && sonuc.status === "success") {
|
||||
console.warn("[odeme] iyzico yanıtında imza alanı yok", { orderId });
|
||||
}
|
||||
|
||||
// conversationId uyuşmazlığı: bu token başka bir siparişe ait, işleme
|
||||
if (sonuc.conversationId && sonuc.conversationId !== order.id) {
|
||||
console.error("[odeme] conversationId uyuşmuyor", { orderId });
|
||||
return order.status;
|
||||
}
|
||||
|
||||
const durum = karar(sonuc);
|
||||
|
||||
if (durum === "pending") {
|
||||
return "pending"; // damgalama: sipariş pending kalır, tekrar sorulur
|
||||
}
|
||||
|
||||
if (durum === "failed") {
|
||||
// paid'i asla geri almayız; pending → failed serbest
|
||||
await appDb
|
||||
.update(orders)
|
||||
.set({ status: "failed" })
|
||||
.where(and(eq(orders.id, orderId), eq(orders.status, "pending")));
|
||||
.where(and(eq(orders.id, orderId), ne(orders.status, "paid")));
|
||||
return "failed";
|
||||
}
|
||||
if (sonuc.conversationId && sonuc.conversationId !== order.id) {
|
||||
return order.status; // conversationId uyuşmazlığı: işleme
|
||||
|
||||
// Tahsil edilen tutar siparişle uyuşmalı — uyuşmuyorsa krediyi otomatik verme
|
||||
const odenenKurus = Math.round(Number(sonuc.paidPrice) * 100);
|
||||
if (!Number.isFinite(odenenKurus) || odenenKurus !== order.amountKurus) {
|
||||
console.error("[odeme] tutar uyuşmuyor", {
|
||||
orderId,
|
||||
beklenen: order.amountKurus,
|
||||
gelen: sonuc.paidPrice,
|
||||
});
|
||||
return order.status;
|
||||
}
|
||||
|
||||
// pending -> paid koşullu geçiş: 0 satır = başka istek zaten işledi
|
||||
// → paid koşullu geçiş: 0 satır = başka istek zaten işledi.
|
||||
// ne(status,'paid') sayesinde erken "failed" damgalanmış bir sipariş de
|
||||
// kurtarılabilir (para çekilmişse kredi mutlaka tanımlanır).
|
||||
const res = await appDb
|
||||
.update(orders)
|
||||
.set({
|
||||
@@ -49,7 +124,7 @@ export async function odemeyiSonuclandir(
|
||||
iyzicoPaymentId: sonuc.paymentId ?? null,
|
||||
paidAt: new Date(),
|
||||
})
|
||||
.where(and(eq(orders.id, orderId), eq(orders.status, "pending")));
|
||||
.where(and(eq(orders.id, orderId), ne(orders.status, "paid")));
|
||||
if (res.rowsAffected === 0) return "paid";
|
||||
|
||||
// UNIQUE(reason, refId) ikinci katman güvence
|
||||
@@ -79,4 +154,4 @@ export async function odemeyiSonuclandir(
|
||||
return "paid";
|
||||
}
|
||||
|
||||
export { URUNLER };
|
||||
export { URUNLER };
|
||||
|
||||
Reference in New Issue
Block a user