From 024a2a49c4337d88630ac1e8eb454a89c5603684 Mon Sep 17 00:00:00 2001
From: bilalgursen
Date: Mon, 21 Sep 2026 16:36:24 +0300
Subject: [PATCH] =?UTF-8?q?fix(odeme):=20=C3=B6nce=20kredi=20sonra=20paid?=
=?UTF-8?q?=20damgas=C4=B1;=20=C3=A7ift=20pakette=20iade=20ak=C4=B1=C5=9F?=
=?UTF-8?q?=C4=B1;=20UNIQUE=20tespiti?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 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
---
docs/odeme/iyzico.md | 19 ++++
src/app/api/odeme/callback/route.ts | 11 +-
src/app/kosullar/page.tsx | 7 ++
.../odeme/components/odeme-sonuc-karti.tsx | 54 +++++++--
src/lib/credits.ts | 72 ++++++++++--
src/lib/odeme.ts | 103 ++++++++++++++++--
6 files changed, 240 insertions(+), 26 deletions(-)
diff --git a/docs/odeme/iyzico.md b/docs/odeme/iyzico.md
index 70e3ac3..4f8d017 100644
--- a/docs/odeme/iyzico.md
+++ b/docs/odeme/iyzico.md
@@ -66,6 +66,25 @@ Ayrıntı: `src/lib/app-url.ts`.
`paid`'e döner. Para çekildiyse kredi mutlaka tanımlanır.
- **Tutar kontrolü**: `paidPrice` sipariş tutarıyla eşleşmiyorsa kredi otomatik
tanımlanmaz, log'a düşer.
+- **Önce kredi, sonra `paid` damgası.** `odemeyiSonuclandir` krediyi yazıp
+ ardından siparişi `paid` yapar. Ters sırada araya kesinti girerse sipariş
+ `paid` görünür ama kredi hiç yazılmaz. `paid` görülen ama defterde kaydı
+ olmayan siparişte kredi yeniden denenir (eski kayıpların onarımı).
+- **Çift paket = iade (Bilal kararı, 21.09.2026).** Paket tek seferliktir:
+ `grantCredits({ paketTekil })` kullanıcı zaten paketliyse yazmaz
+ (`neden: "zaten-paketli"`, kontrol bakiye UPDATE'inin WHERE'inde → eşzamanlı
+ iki ödemede de tek paket). Sipariş yine `paid` damgalanır, `odeme_tamamlandi`
+ atılmaz, `destek@kolaytercih.com`'a "İade gerekli" e-postası gider ve
+ `/odeme/sonuc` "Bu ödeme iade edilecek" kartını gösterir
+ (`iadeEdilecekMi`: paid + paket + defterde kayıt yok). **İade otomatik
+ DEĞİL**: iyzico panelinden elle yapılır. İade sonrası defter satırı silinmez;
+ gerekirse ters kayıt atılır.
+- **Haftalık mutabakat (salt okunur).** Ödenmiş ama kredisi yazılmamış siparişler:
+ `SELECT o.id, o.user_id, u.has_paket FROM orders o JOIN user u ON u.id=o.user_id
+ LEFT JOIN credit_ledger l ON l.ref_id=o.id AND l.reason IN ('purchase','topup')
+ WHERE o.status='paid' AND l.id IS NULL;` — `has_paket=0` satırlar kullanıcının
+ ilk `/odeme/sonuc` ziyaretinde kendiliğinden onarılır; `has_paket=1` satırlar
+ iade bekleyen çift paket ödemesidir (e-posta kaçmış olabilir → elle iade et).
## İmza doğrulaması
diff --git a/src/app/api/odeme/callback/route.ts b/src/app/api/odeme/callback/route.ts
index 717aabd..6ea77a5 100644
--- a/src/app/api/odeme/callback/route.ts
+++ b/src/app/api/odeme/callback/route.ts
@@ -36,7 +36,16 @@ export async function POST(request: NextRequest) {
return NextResponse.redirect(`${appUrl}/paket?hata=siparis`, 303);
}
- await odemeyiSonuclandir(order.id);
+ // Geçici DB hatası (yazma kilidi vb.) kullanıcıya çıplak 500 göstermesin:
+ // sonuç sayfası aynı sonuçlandırmayı yeniden dener (self-healing).
+ try {
+ await odemeyiSonuclandir(order.id);
+ } catch (e) {
+ console.error("[odeme] callback sonuçlandırma hatası", {
+ siparis: order.id,
+ e,
+ });
+ }
// 303: iyzico'nun POST'u GET'e döner, kullanıcı sonuç sayfasında yenileme
// yaptığında form yeniden gönderilmez.
return NextResponse.redirect(`${appUrl}/odeme/sonuc?siparis=${order.id}`, 303);
diff --git a/src/app/kosullar/page.tsx b/src/app/kosullar/page.tsx
index c62ce22..7a318fd 100644
--- a/src/app/kosullar/page.tsx
+++ b/src/app/kosullar/page.tsx
@@ -75,6 +75,13 @@ export default function KosullarPage() {
; talepler 14 gün içinde sonuçlandırılır ve iade, ödeme yaptığın
karta yapılır.
+
+ Paket hesap başına tek seferliktir. Paketin zaten aktifken
+ yanlışlıkla ikinci kez ödeme yaptıysan (örneğin iki cihazdan aynı
+ anda), bu ikinci ödeme için kredi tanımlanmaz ve tutarın tamamı,
+ talep etmene gerek kalmadan, en geç 14 gün içinde ödeme yaptığın
+ karta iade edilir.
+
diff --git a/src/features/odeme/components/odeme-sonuc-karti.tsx b/src/features/odeme/components/odeme-sonuc-karti.tsx
index 53f2eb3..2a872e3 100644
--- a/src/features/odeme/components/odeme-sonuc-karti.tsx
+++ b/src/features/odeme/components/odeme-sonuc-karti.tsx
@@ -1,9 +1,9 @@
import Link from "next/link";
-import { ArrowRight, CheckCircle2, XCircle } from "lucide-react";
+import { ArrowRight, CheckCircle2, RotateCcw, XCircle } from "lucide-react";
import { verifySession } from "@/lib/session";
import { siparisGetir } from "../odeme-queries";
import { kullanicininRaporu } from "@/features/rapor/rapor-queries";
-import { odemeyiSonuclandir } from "@/lib/odeme";
+import { iadeEdilecekMi, odemeyiSonuclandir } from "@/lib/odeme";
import { Button } from "@/components/ui/button";
import { PagePixelDivider } from "@/components/pixel-decor";
import { Skeleton } from "@/components/ui/skeleton";
@@ -23,16 +23,54 @@ export async function OdemeSonucKarti({ siparis }: { siparis?: string }) {
// Yalnızca kendi siparişi görüntülenebilir
const sahibiMi = order?.userId === session.user.id;
- // Self-healing: callback kaybolduysa burada tekrar doğrula
+ // Self-healing: callback kaybolduysa burada tekrar doğrula. "paid" siparişte
+ // de çağrılır — kredisi yazılmamış eski kayıtları onarır (idempotent).
const durum =
- order && sahibiMi
- ? order.status === "paid"
- ? "paid"
- : await odemeyiSonuclandir(order.id)
- : "not_found";
+ order && sahibiMi ? await odemeyiSonuclandir(order.id) : "not_found";
const basarili = durum === "paid";
+ // Zaten paketliyken alınan ikinci paket ödemesi: kredi yazılmadı, iade edilir
+ const iade =
+ basarili && order
+ ? await iadeEdilecekMi({ ...order, status: "paid" })
+ : false;
+ if (iade) {
+ return (
+
+
+
+ Bu ödeme iade edilecek
+
+
+ Tercih Dönemi Paketi'n zaten aktifti; paket tek seferlik olduğu
+ için bu ikinci ödemeyi iade ediyoruz — senin bir şey yapmana gerek
+ yok. İade en geç 14 gün içinde yapılır; kartına yansıması bankana
+ göre birkaç gün daha sürebilir. Sorun olursa{" "}
+
+ destek@kolaytercih.com
+
+ 'a yaz.
+
+
+
+
+
+
+ );
+ }
+
// Rapor zaten üretildiyse ödül bir tık uzakta: kullanıcıyı doğrudan
// rapor sayfasına döndür ki blur çözülme animasyonuyla tam listeyi görsün.
const rapor = basarili ? await kullanicininRaporu(session.user.id) : null;
diff --git a/src/lib/credits.ts b/src/lib/credits.ts
index b207935..280a238 100644
--- a/src/lib/credits.ts
+++ b/src/lib/credits.ts
@@ -16,6 +16,30 @@ export const RAPOR_KREDI = 3;
/** Paketle gelen geri bildirimli liste revizyonu hakkı */
export const MAX_REVIZYON = 2;
+/**
+ * UNIQUE ihlali mi? drizzle sürücü hatasını DrizzleQueryError'a sarar: üst
+ * mesaj "Failed query: …" olur, "UNIQUE" yalnız `cause` zincirinde kalır. Yalnız
+ * üst mesaja bakmak idempotent no-op yerine hatayı yukarı fırlatıyordu.
+ */
+function uniqueIhlaliMi(err: unknown): boolean {
+ for (let e: unknown = err, i = 0; e != null && i < 5; i++) {
+ const h = e as {
+ message?: unknown;
+ code?: unknown;
+ extendedCode?: unknown;
+ cause?: unknown;
+ };
+ if (
+ String(h.message ?? "").includes("UNIQUE") ||
+ String(h.extendedCode ?? h.code ?? "").includes("CONSTRAINT_UNIQUE")
+ ) {
+ return true;
+ }
+ e = h.cause;
+ }
+ return false;
+}
+
export type SpendResult =
| { ok: true }
| { ok: false; error: "INSUFFICIENT" | "DUPLICATE" };
@@ -61,7 +85,7 @@ export async function spendCredits(opts: {
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
if (msg === "INSUFFICIENT") return { ok: false, error: "INSUFFICIENT" };
- if (msg.includes("UNIQUE")) return { ok: false, error: "DUPLICATE" };
+ if (uniqueIhlaliMi(err)) return { ok: false, error: "DUPLICATE" };
throw err;
}
}
@@ -108,14 +132,22 @@ export async function spendCreditForMessage(opts: {
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
if (msg === "INSUFFICIENT") return { ok: false, error: "INSUFFICIENT" };
- if (msg.includes("UNIQUE")) return { ok: false, error: "DUPLICATE" };
+ if (uniqueIhlaliMi(err)) return { ok: false, error: "DUPLICATE" };
throw err;
}
}
+/** `paketTekil` ile çağrılan tanımlamada kullanıcı zaten paketliyse fırlatılır. */
+class ZatenPaketli extends Error {}
+
/**
* Kredi tanımlama (deneme, satın alma, top-up, iade). UNIQUE(reason, refId)
* sayesinde aynı referansla ikinci çağrı sessizce no-op olur → idempotent.
+ *
+ * `paketTekil`: paket tek seferliktir. Kullanıcı başka bir siparişle zaten
+ * paketliyse kredi YAZILMAZ (`neden: "zaten-paketli"`); çağıran bu ödemeyi
+ * iade edilecek diye işaretler. Kontrol, bakiye güncellemesinin WHERE'inde
+ * yapılır ki eşzamanlı iki ödeme de tek paket yazabilsin.
*/
export async function grantCredits(opts: {
userId: string;
@@ -123,7 +155,8 @@ export async function grantCredits(opts: {
reason: "trial_grant" | "purchase" | "topup" | "refund";
refId: string;
setHasPaket?: boolean;
-}): Promise<{ granted: boolean }> {
+ paketTekil?: boolean;
+}): Promise<{ granted: boolean; neden?: "tekrar" | "zaten-paketli" }> {
try {
await appDb.transaction(async (tx) => {
await tx.insert(creditLedger).values({
@@ -134,7 +167,7 @@ export async function grantCredits(opts: {
refId: opts.refId,
createdAt: new Date(),
});
- await tx
+ const res = await tx
.update(user)
.set({
creditBalance: sql`${user.creditBalance} + ${opts.delta}`,
@@ -143,16 +176,41 @@ export async function grantCredits(opts: {
krediBittiAt: null,
updatedAt: new Date(),
})
- .where(eq(user.id, opts.userId));
+ .where(
+ opts.paketTekil
+ ? and(eq(user.id, opts.userId), eq(user.hasPaket, false))
+ : eq(user.id, opts.userId),
+ );
+ // 0 satır: kullanıcı zaten paketli → defter kaydını da geri al
+ if (opts.paketTekil && res.rowsAffected === 0) throw new ZatenPaketli();
});
return { granted: true };
} catch (err) {
- const msg = err instanceof Error ? err.message : String(err);
- if (msg.includes("UNIQUE")) return { granted: false };
+ if (err instanceof ZatenPaketli) {
+ return { granted: false, neden: "zaten-paketli" };
+ }
+ if (uniqueIhlaliMi(err)) return { granted: false, neden: "tekrar" };
throw err;
}
}
+/**
+ * Bu siparişin kredisi deftere düşmüş mü? `reason` şart: UNIQUE(reason, ref_id)
+ * indeksi kullanılsın (yalnız ref_id tam tablo tarar) ve aynı refId'li bir
+ * `refund` satırı "kredi yazılmış" sayılmasın.
+ */
+export async function krediKaydiVarMi(
+ refId: string,
+ reason: "purchase" | "topup",
+): Promise {
+ const rows = await appDb
+ .select({ id: creditLedger.id })
+ .from(creditLedger)
+ .where(and(eq(creditLedger.reason, reason), eq(creditLedger.refId, refId)))
+ .limit(1);
+ return rows.length > 0;
+}
+
/**
* Kredi-bitti anını işaretler (48 saatlik hatırlatma e-postasının saati).
* Yalnızca ilk bitişte yazar (kolon doluysa no-op) ve hatırlatma zaten
diff --git a/src/lib/odeme.ts b/src/lib/odeme.ts
index 38adb90..727e769 100644
--- a/src/lib/odeme.ts
+++ b/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: `
${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.
@@ -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).