All checks were successful
Deploy / deploy (push) Successful in 8m50s
- odemeyiSonuclandir krediyi yazıp sonra paid damgalar; araya kesinti girerse
sipariş pending kalır ve yeniden denenir (eskiden: paid görünür, kredi yok).
paid + deftersiz eski siparişler ilk ziyarette onarılır
- paket tek seferlik: grantCredits({ paketTekil }) zaten paketliye yazmaz
(kontrol UPDATE'in WHERE'inde). Sipariş paid damgalanır, senkron log +
destek@ e-postası, /odeme/sonuc 'Bu ödeme iade edilecek' kartı, /kosullar#iade
- drizzle DrizzleQueryError üst mesajında 'UNIQUE' yok → idempotent no-op yerine
hata fırlıyordu (grant + iki spend yolu); cause zincirine bakılıyor
- callback geçici DB hatasında 500 yerine sonuç sayfasına yönlendirir
- krediKaydiVarMi (reason, ref_id) indeksini kullanır; haftalık mutabakat sorgusu
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
242 lines
8.8 KiB
TypeScript
242 lines
8.8 KiB
TypeScript
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: `<pre style="font-family:inherit;white-space:pre-wrap">${metin}</pre>`,
|
||
});
|
||
} 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<boolean> {
|
||
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<SiparisDurumu> {
|
||
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 };
|