fix(odeme): action redirect fırlatmaz, hedef döndürür + çift paket çekimine sunucu koruması
All checks were successful
Deploy / deploy (push) Successful in 8m46s

- baslatOdeme { error } | { yonlendir } döndürür; SatinAlForm client'ta
  router.push yapar (cacheComponents altında geri dönüşte buton tıklamaya
  sağır kalıyordu); tıklamada ok → dönen halka
- paketi olan yeniden 'paket', paketi olmayan 'topup' başlatamaz
- /odeme, paket alınmışken eski bekleyen paket siparişinin formunu göstermez

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
bilalgursen
2026-09-21 16:13:30 +03:00
parent d7c29ac4a6
commit 8f4669e31d
4 changed files with 93 additions and 32 deletions

View File

@@ -1,7 +1,8 @@
# iyzico ödeme entegrasyonu
Ödeme akışı iyzico **Checkout Form (CF)** üzerinden yürür: kart bilgisi hiçbir
zaman bize ulaşmaz, kullanıcı iyzico'nun barındırdığı ödeme sayfasına gider.
zaman bize ulaşmaz; kart formu iyzico iframe'i olarak kendi `/odeme` sayfamıza
gömülür.
## Ortam değişkenleri
@@ -29,8 +30,13 @@ Ayrıntı: `src/lib/app-url.ts`.
1. `baslatOdeme` (`src/features/odeme/odeme-actions.ts`) — `orders` satırını
`pending` olarak yazar, `checkoutFormInitialize` çağırır, dönen `token`'ı
siparişe iliştirir ve kullanıcıyı `paymentPageUrl`'e yönlendirir.
siparişe iliştirir ve `/odeme?siparis=<id>` adresini **döndürür** (`redirect()`
fırlatmaz); yönlendirmeyi `SatinAlForm` client'ta `router.push` ile yapar.
`conversationId` = `basketId` = sipariş id'miz.
Oturum yoksa `/giris?callback=/paket` döner. Sunucu koruması: paketi olan
yeniden `paket`, paketi olmayan `topup` başlatamaz; `/odeme` sayfası da
paket alınmışken eski bekleyen paket siparişinin formunu göstermez
(bayat sekme / ikinci cihazdan çift çekim önlemi).
2. Kullanıcı ödemeyi bitirince iyzico `/api/odeme/callback` adresine
**cross-site POST** atar; gövdede yalnızca `token` vardır. SameSite=Lax
nedeniyle session çerezi gelmez, bu yüzden kullanıcı token'dan çözülür.

View File

@@ -2,7 +2,7 @@ import Image from "next/image";
import Link from "next/link";
import { notFound, redirect } from "next/navigation";
import { Lock } from "lucide-react";
import { verifySession } from "@/lib/session";
import { getCurrentUser, verifySession } from "@/lib/session";
import { SectionEyebrow } from "@/components/pixel-decor";
import { Skeleton } from "@/components/ui/skeleton";
import { siparisGetir } from "../odeme-queries";
@@ -28,6 +28,12 @@ export async function OdemeSayfasi({ siparisId }: { siparisId?: string }) {
if (order.status === "failed" || !order.iyzicoFormContent) {
redirect("/paket");
}
// Paket bu arada alındıysa (başka sekme/cihaz, geri tuşu) eski bekleyen
// paket siparişinin formu yeniden çizilmesin — ikinci kez ücret çekilmesin.
if (order.product === "paket") {
const kullanici = await getCurrentUser();
if (kullanici?.hasPaket) redirect("/paket");
}
return (
<div className="w-full">

View File

@@ -1,11 +1,15 @@
"use client";
import { useActionState, useEffect } from "react";
import { ArrowRight } from "lucide-react";
import { useLayoutEffect, useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import { ArrowRight, Loader2 } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { olay } from "@/lib/analitik";
import { baslatOdeme, type OdemeBaslatDurum } from "../odeme-actions";
import { baslatOdeme } from "../odeme-actions";
const BAGLANTI_HATASI =
"Ödeme başlatılamadı; kartından çekim yapılmadı. Bağlantını kontrol edip tekrar dener misin?";
export function SatinAlForm({
urun,
@@ -16,24 +20,50 @@ export function SatinAlForm({
label: string;
vurgulu?: boolean;
}) {
const [state, formAction, pending] = useActionState<
OdemeBaslatDurum,
FormData
>(baslatOdeme, undefined);
const router = useRouter();
const [pending, startTransition] = useTransition();
// Action bitti, /odeme'ye geçiş sürüyor: buton o arada yeniden açılıp ikinci
// bir sipariş başlatmasın.
const [gidiyor, setGidiyor] = useState(false);
const mesgul = pending || gidiyor;
useEffect(() => {
if (state?.error) toast.error(state.error);
}, [state]);
// cacheComponents açıkken /paket, /odeme'ye geçince unmount olmaz; gizli
// <Activity> içinde state'iyle saklanır. Geri dönen kullanıcı kilitli değil
// taze bir buton bulsun diye sayfa gizlenirken sıfırlanır (layout effect
// cleanup'ı Activity gizlenince çalışır).
useLayoutEffect(() => {
return () => setGidiyor(false);
}, []);
// Yönlendirme client'ta: action `redirect()` fırlatmaz, hedefi döndürür
// (nedeni odeme-actions.ts'te). Hata toast'ı da burada, effect'te değil —
// effect'ler Activity geri açılınca yeniden koştuğu için eski hatayı
// tekrar gösteriyordu.
function gonder() {
olay("odeme_baslatildi", { urun });
startTransition(async () => {
let sonuc;
try {
sonuc = await baslatOdeme(urun);
} catch {
toast.error(BAGLANTI_HATASI);
return;
}
if ("error" in sonuc) {
toast.error(sonuc.error);
return;
}
setGidiyor(true);
router.push(sonuc.yonlendir);
});
}
return (
<form
action={formAction}
onSubmit={() => olay("odeme_baslatildi", { urun })}
>
<input type="hidden" name="urun" value={urun} />
<form action={gonder}>
<Button
type="submit"
disabled={pending}
disabled={mesgul}
aria-busy={mesgul}
className={
vurgulu
? "h-12 w-full cursor-pointer bg-orange-500 px-8 text-white transition-colors duration-200 hover:bg-orange-600"
@@ -41,11 +71,15 @@ export function SatinAlForm({
}
variant={vurgulu ? "default" : "outline"}
>
{/* Tıklamada buton içeriği değişmez (metin/spinner takası yok); dar
top-up kartında taşmaya yol açıyordu. Çift gönderim `disabled`
ile engellenir. */}
{/* Tıklamada metin değişmez (dar top-up kartında taşıyordu); geri
bildirim aynı boyuttaki ikon takasıyla verilir: ok → dönen halka.
Çift gönderim `disabled` ile engellenir. */}
{label}
<ArrowRight className="size-4" aria-hidden />
{mesgul ? (
<Loader2 className="size-4 animate-spin" aria-hidden />
) : (
<ArrowRight className="size-4" aria-hidden />
)}
</Button>
</form>
);

View File

@@ -2,14 +2,18 @@
import { eq } from "drizzle-orm";
import { headers } from "next/headers";
import { redirect } from "next/navigation";
import { verifySession } from "@/lib/session";
import { getCurrentUser, getSession } from "@/lib/session";
import { appUrl as siteKok } from "@/lib/app-url";
import { appDb, schema } from "@/lib/appdb";
import { URUNLER } from "@/lib/credits";
import { initializeCheckoutForm, initImzaDurumu } from "@/lib/iyzico";
export type OdemeBaslatDurum = { error?: string } | undefined;
// Action bilerek `redirect()` FIRLATMAZ, hedefi döndürür; yönlendirmeyi client
// yapar (bkz. satin-al-form.tsx). Fırlatılan redirect, client'ta action
// promise'ini reddedip formu RedirectBoundary üzerinden yeniden mount ediyor;
// cacheComponents altında /paket gizli <Activity> içinde canlı tutulduğu için
// bu yarım kalmış durum geri dönüşte butonu tıklamalara sağır bırakıyordu.
export type OdemeBaslatDurum = { error: string } | { yonlendir: string };
const GENEL_HATA =
"Ödeme şu anda başlatılamıyor; kartından çekim yapılmadı. Birazdan tekrar dener misin?";
@@ -23,15 +27,26 @@ function adSoyadAyir(tamAd: string): [string, string] {
}
export async function baslatOdeme(
_prev: OdemeBaslatDurum,
formData: FormData,
product: "paket" | "topup",
): Promise<OdemeBaslatDurum> {
// Ucuz senkron doğrulama pahalı oturum turundan önce
const product = formData.get("urun");
// Ucuz senkron doğrulama pahalı oturum turundan önce (argüman client'tan
// gelir; tipine güvenilmez)
if (product !== "paket" && product !== "topup") {
return { error: "Geçersiz ürün." };
}
const session = await verifySession("/paket");
const session = await getSession();
if (!session) {
return { yonlendir: `/giris?callback=${encodeURIComponent("/paket")}` };
}
// Arayüz bu kuralları zaten uygular ama bayat sekmeden (ör. paket başka
// sekmede alındı) gelen istek ikinci kez ücret çekmesin.
const kullanici = await getCurrentUser();
if (product === "paket" && kullanici?.hasPaket) {
return { error: "Paketin zaten aktif; yeniden ödeme alınmadı." };
}
if (product === "topup" && !kullanici?.hasPaket) {
return { error: "Ek kredi için önce paketi alman gerekiyor." };
}
const urun = URUNLER[product];
const orderId = crypto.randomUUID();
@@ -100,5 +115,5 @@ export async function baslatOdeme(
.set({ iyzicoToken: init.token, iyzicoFormContent: init.checkoutFormContent })
.where(eq(schema.orders.id, orderId));
redirect(`/odeme?siparis=${orderId}`);
return { yonlendir: `/odeme?siparis=${orderId}` };
}