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 # iyzico ödeme entegrasyonu
Ödeme akışı iyzico **Checkout Form (CF)** üzerinden yürür: kart bilgisi hiçbir Ö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 ## 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ı 1. `baslatOdeme` (`src/features/odeme/odeme-actions.ts`) — `orders` satırını
`pending` olarak yazar, `checkoutFormInitialize` çağırır, dönen `token`'ı `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. `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 2. Kullanıcı ödemeyi bitirince iyzico `/api/odeme/callback` adresine
**cross-site POST** atar; gövdede yalnızca `token` vardır. SameSite=Lax **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. 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 Link from "next/link";
import { notFound, redirect } from "next/navigation"; import { notFound, redirect } from "next/navigation";
import { Lock } from "lucide-react"; import { Lock } from "lucide-react";
import { verifySession } from "@/lib/session"; import { getCurrentUser, verifySession } from "@/lib/session";
import { SectionEyebrow } from "@/components/pixel-decor"; import { SectionEyebrow } from "@/components/pixel-decor";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import { siparisGetir } from "../odeme-queries"; import { siparisGetir } from "../odeme-queries";
@@ -28,6 +28,12 @@ export async function OdemeSayfasi({ siparisId }: { siparisId?: string }) {
if (order.status === "failed" || !order.iyzicoFormContent) { if (order.status === "failed" || !order.iyzicoFormContent) {
redirect("/paket"); 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 ( return (
<div className="w-full"> <div className="w-full">

View File

@@ -1,11 +1,15 @@
"use client"; "use client";
import { useActionState, useEffect } from "react"; import { useLayoutEffect, useState, useTransition } from "react";
import { ArrowRight } from "lucide-react"; import { useRouter } from "next/navigation";
import { ArrowRight, Loader2 } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { olay } from "@/lib/analitik"; 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({ export function SatinAlForm({
urun, urun,
@@ -16,24 +20,50 @@ export function SatinAlForm({
label: string; label: string;
vurgulu?: boolean; vurgulu?: boolean;
}) { }) {
const [state, formAction, pending] = useActionState< const router = useRouter();
OdemeBaslatDurum, const [pending, startTransition] = useTransition();
FormData // Action bitti, /odeme'ye geçiş sürüyor: buton o arada yeniden açılıp ikinci
>(baslatOdeme, undefined); // bir sipariş başlatmasın.
const [gidiyor, setGidiyor] = useState(false);
const mesgul = pending || gidiyor;
useEffect(() => { // cacheComponents açıkken /paket, /odeme'ye geçince unmount olmaz; gizli
if (state?.error) toast.error(state.error); // <Activity> içinde state'iyle saklanır. Geri dönen kullanıcı kilitli değil
}, [state]); // 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 ( return (
<form <form action={gonder}>
action={formAction}
onSubmit={() => olay("odeme_baslatildi", { urun })}
>
<input type="hidden" name="urun" value={urun} />
<Button <Button
type="submit" type="submit"
disabled={pending} disabled={mesgul}
aria-busy={mesgul}
className={ className={
vurgulu vurgulu
? "h-12 w-full cursor-pointer bg-orange-500 px-8 text-white transition-colors duration-200 hover:bg-orange-600" ? "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"} variant={vurgulu ? "default" : "outline"}
> >
{/* Tıklamada buton içeriği değişmez (metin/spinner takası yok); dar {/* Tıklamada metin değişmez (dar top-up kartında taşıyordu); geri
top-up kartında taşmaya yol açıyordu. Çift gönderim `disabled` bildirim aynı boyuttaki ikon takasıyla verilir: ok → dönen halka.
ile engellenir. */} Çift gönderim `disabled` ile engellenir. */}
{label} {label}
{mesgul ? (
<Loader2 className="size-4 animate-spin" aria-hidden />
) : (
<ArrowRight className="size-4" aria-hidden /> <ArrowRight className="size-4" aria-hidden />
)}
</Button> </Button>
</form> </form>
); );

View File

@@ -2,14 +2,18 @@
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { headers } from "next/headers"; import { headers } from "next/headers";
import { redirect } from "next/navigation"; import { getCurrentUser, getSession } from "@/lib/session";
import { verifySession } from "@/lib/session";
import { appUrl as siteKok } from "@/lib/app-url"; import { appUrl as siteKok } from "@/lib/app-url";
import { appDb, schema } from "@/lib/appdb"; import { appDb, schema } from "@/lib/appdb";
import { URUNLER } from "@/lib/credits"; import { URUNLER } from "@/lib/credits";
import { initializeCheckoutForm, initImzaDurumu } from "@/lib/iyzico"; 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 = const GENEL_HATA =
"Ödeme şu anda başlatılamıyor; kartından çekim yapılmadı. Birazdan tekrar dener misin?"; "Ö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( export async function baslatOdeme(
_prev: OdemeBaslatDurum, product: "paket" | "topup",
formData: FormData,
): Promise<OdemeBaslatDurum> { ): Promise<OdemeBaslatDurum> {
// Ucuz senkron doğrulama pahalı oturum turundan önce // Ucuz senkron doğrulama pahalı oturum turundan önce (argüman client'tan
const product = formData.get("urun"); // gelir; tipine güvenilmez)
if (product !== "paket" && product !== "topup") { if (product !== "paket" && product !== "topup") {
return { error: "Geçersiz ürün." }; 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 urun = URUNLER[product];
const orderId = crypto.randomUUID(); const orderId = crypto.randomUUID();
@@ -100,5 +115,5 @@ export async function baslatOdeme(
.set({ iyzicoToken: init.token, iyzicoFormContent: init.checkoutFormContent }) .set({ iyzicoToken: init.token, iyzicoFormContent: init.checkoutFormContent })
.where(eq(schema.orders.id, orderId)); .where(eq(schema.orders.id, orderId));
redirect(`/odeme?siparis=${orderId}`); return { yonlendir: `/odeme?siparis=${orderId}` };
} }