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).