fix(odeme): önce kredi sonra paid damgası; çift pakette iade akışı; UNIQUE tespiti
All checks were successful
Deploy / deploy (push) Successful in 8m50s
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>
This commit is contained in:
103
src/lib/odeme.ts
103
src/lib/odeme.ts
@@ -2,13 +2,14 @@ import "server-only";
|
||||
import { after } from "next/server";
|
||||
import { and, eq, ne } from "drizzle-orm";
|
||||
import { appDb, schema } from "./appdb";
|
||||
import { grantCredits, URUNLER } from "./credits";
|
||||
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;
|
||||
|
||||
@@ -46,6 +47,54 @@ function karar(
|
||||
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.
|
||||
@@ -58,7 +107,25 @@ export async function odemeyiSonuclandir(
|
||||
where: eq(orders.id, orderId),
|
||||
});
|
||||
if (!order) return "not_found";
|
||||
if (order.status === "paid") return "paid";
|
||||
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;
|
||||
@@ -114,6 +181,20 @@ export async function odemeyiSonuclandir(
|
||||
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).
|
||||
@@ -128,14 +209,16 @@ export async function odemeyiSonuclandir(
|
||||
.where(and(eq(orders.id, orderId), ne(orders.status, "paid")));
|
||||
if (res.rowsAffected === 0) return "paid";
|
||||
|
||||
// UNIQUE(reason, refId) ikinci katman güvence
|
||||
await grantCredits({
|
||||
userId: order.userId,
|
||||
delta: order.credits,
|
||||
reason: order.product === "paket" ? "purchase" : "topup",
|
||||
refId: order.id,
|
||||
setHasPaket: order.product === "paket",
|
||||
});
|
||||
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).
|
||||
|
||||
Reference in New Issue
Block a user