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