Files
kolaytercih/src/lib/odeme.ts
bilalgursen 024a2a49c4
All checks were successful
Deploy / deploy (push) Successful in 8m50s
fix(odeme): önce kredi sonra paid damgası; çift pakette iade akışı; UNIQUE tespiti
- 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>
2026-09-21 16:36:24 +03:00

242 lines
8.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 };