import "server-only"; import { after } from "next/server"; import { and, eq, ne } from "drizzle-orm"; import { appDb, schema } from "./appdb"; import { grantCredits, krediKaydiVarMi, URUNLER } from "./credits"; import { retrieveCheckoutForm, retrieveImzaDurumu, type CheckoutFormSonuc, } from "./iyzico"; import { sunucuOlayi } from "./analitik-sunucu"; import { epostaGonder } from "./eposta-gonder"; 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"; } type Siparis = typeof orders.$inferSelect; const DESTEK_ADRESI = "destek@kolaytercih.com"; function krediNedeni(order: Siparis) { return order.product === "paket" ? ("purchase" as const) : ("topup" as const); } /** Siparişin kredisini idempotent tanımlar; paket tek seferliktir. */ function krediyiTanimla(order: Siparis) { return grantCredits({ userId: order.userId, delta: order.credits, reason: krediNedeni(order), refId: order.id, setHasPaket: order.product === "paket", paketTekil: order.product === "paket", }); } /** * Zaten paketli kullanıcıdan ikinci paket ödemesi alındı (iki cihaz/sekme aynı * anda ödedi). Politika: kredi yazılmaz, tutar iade edilir. İade iyzico * panelinden elle yapılır; burada yalnızca iz + destek kutusuna haber. */ async function ciftPaketBildir(order: Siparis, paymentId: string | null) { try { const metin = `Zaten paketli bir kullanıcıdan ikinci paket ödemesi alındı. Kredi yazılmadı; tutarın iyzico panelinden iade edilmesi gerekiyor.\n\nSipariş: ${order.id}\niyzico ödeme no: ${paymentId ?? "—"}\nTutar: ${(order.amountKurus / 100).toFixed(2)} TL\nKullanıcı: ${order.userId}`; await epostaGonder(DESTEK_ADRESI, { subject: "İade gerekli: çift paket ödemesi", text: metin, html: `
${metin}
`, }); } catch (err) { console.error("[odeme] çift paket bildirimi gönderilemedi", err); } } /** * Ödenmiş ama kredisi yazılmamış paket siparişi = iade edilecek çift ödeme * (krediyiTanimla her paid siparişte kredi yazmayı dener; yazılamamasının tek * kalıcı nedeni kullanıcının başka siparişle zaten paketli olmasıdır). */ export async function iadeEdilecekMi(order: Siparis): Promise { if (order.status !== "paid" || order.product !== "paket") return false; return !(await krediKaydiVarMi(order.id, "purchase")); } /** * Token'la iyzico'dan sonucu çeker ve başarılıysa krediyi İDEMPOTENT tanımlar. * 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 { const order = await appDb.query.orders.findFirst({ where: eq(orders.id, orderId), }); if (!order) return "not_found"; if (order.status === "paid") { // Onarım: eski sürümde paid damgası krediden ÖNCE yazılıyordu; araya // kesinti girdiyse kredi hiç yazılmamış olabilir. İdempotent olduğu için // yeniden denemek güvenli (çift paket ise "zaten-paketli" döner, yazmaz). try { if (!(await krediKaydiVarMi(order.id, krediNedeni(order)))) { const k = await krediyiTanimla(order); if (k.neden === "zaten-paketli") { console.error("[odeme] iade bekleyen çift paket", { orderId }); } } } catch (err) { // "paid" dönersek sonuç sayfası deftersiz siparişi "iade edilecek" sanar; // pending → "hâlâ işleniyor, yenile" kartı (doğrusu bu). console.error("[odeme] kredi onarımı başarısız", { orderId, err }); return "pending"; } return "paid"; } if (!order.iyzicoToken) return order.status; let sonuc; try { sonuc = await retrieveCheckoutForm(order.iyzicoToken); } catch { return order.status; // iyzico'ya ulaşılamadı; durumu değiştirme } // İ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", iyzicoFormContent: null }) .where(and(eq(orders.id, orderId), ne(orders.status, "paid"))); return "failed"; } // 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; } // Önce kredi, sonra paid damgası. Ters sırada araya kesinti (deploy, ağ) // girerse sipariş "paid" görünür ama kredi hiç yazılmaz ve hiçbir yol // kurtarmaz. Bu sırada kesinti olursa sipariş pending kalır; webhook ya da // /odeme/sonuc yeniden dener, UNIQUE(reason, refId) çift yazımı engeller. let kredi; try { kredi = await krediyiTanimla(order); } catch (err) { // Geçici DB hatası (kilit, ağ): sipariş pending kalır, sonraki tetikleyici // (webhook yeniden denemesi, /odeme/sonuc) krediyi yazar. console.error("[odeme] kredi yazılamadı, yeniden denenecek", { orderId, err }); return order.status; } // → 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({ status: "paid", iyzicoPaymentId: sonuc.paymentId ?? null, iyzicoFormContent: null, paidAt: new Date(), }) .where(and(eq(orders.id, orderId), ne(orders.status, "paid"))); if (res.rowsAffected === 0) return "paid"; if (kredi.neden === "zaten-paketli") { // Log senkron: after() koşmadan süreç ölse ya da e-posta gitmese de iz kalsın console.error("[odeme] ÇİFT PAKET — iade gerekli", { orderId, userId: order.userId, paymentId: sonuc.paymentId ?? null, }); after(() => ciftPaketBildir(order, sonuc.paymentId ?? null)); return "paid"; } // Dönüşüm kaydı. Koşullu geçişin ardında olduğu için sipariş başına tam bir // kez düşer (self-healing fallback tekrar çağrılsa da rowsAffected 0 döner). // after(): yanıt gönderildikten sonra koşar — callback redirect'ini ve // /odeme/sonuc render'ını Rybbit'in 3 sn'lik timeout'u bloklayamaz. after(() => sunucuOlayi("odeme_tamamlandi", { kullaniciId: order.userId, pathname: "/odeme/sonuc", ozellikler: { urun: order.product, kredi: order.credits, tutar: order.amountKurus / 100, }, }), ); return "paid"; } export { URUNLER };