chore: update iyzico integration and enhance payment processing logic
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:
bilalgursen
2026-08-11 00:30:53 +03:00
parent 70b254c3b2
commit 08419e6d1c
12 changed files with 1417 additions and 44 deletions

View File

@@ -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 };